Skip to main content

vole_document/field/
document_format.rs

1//! Byte-based document-format detection for the universal observation API
2//! (Phase 12.7, ADR-0031, plan §DEC-5).
3//!
4//! The format of a field is decided from its **source bytes**, never from a file
5//! name or extension. Detection is deliberately conservative: an ambiguous or
6//! malformed input falls back to [`DocumentFormat::Opaque`] rather than guessing,
7//! and a format whose adapter is not compiled in cannot be detected (the input is
8//! then `Opaque`), so the reported capability set always matches what the build
9//! can actually serve.
10//!
11//! * **PDF** — the byte-authoritative physical scanner admits a validated PDF
12//!   ([`crate::adapter::pdf::detect`]).
13//! * **DOCX** — a valid ZIP that also carries the OPC content-types part
14//!   (`[Content_Types].xml`) and a package `officeDocument` relationship.
15//! * **EPUB** — a valid ZIP that is an OCF container: the mandatory stored
16//!   `mimetype` member equals `application/epub+zip`, or `META-INF/container.xml`
17//!   names the OCF namespace and an OPF (`application/oebps-package+xml`) rootfile.
18//! * **ODT** — a valid ZIP that is an OpenDocument (ODF) package: the mandatory
19//!   stored `mimetype` member is an OpenDocument *text* media type, or
20//!   `META-INF/manifest.xml` declares one.
21//! * **ODS** — a valid ZIP that is an OpenDocument (ODF) package whose mandatory
22//!   stored `mimetype` member is an OpenDocument *spreadsheet* media type, or whose
23//!   `META-INF/manifest.xml` declares one (Phase 21.3.1). Mutually exclusive with
24//!   ODT (a text document declares the text media type, a spreadsheet the
25//!   spreadsheet one).
26//! * **ODP** — a valid ZIP that is an OpenDocument (ODF) package whose mandatory
27//!   stored `mimetype` member is an OpenDocument *presentation* media type, or whose
28//!   `META-INF/manifest.xml` declares one (Phase 21.4.1). Mutually exclusive with
29//!   ODT/ODS (a presentation declares the presentation media type).
30//! * **Opaque** — everything else, including a ZIP that matches none of the above
31//!   (or more than one — an ambiguous ZIP fails safe).
32//! * **JSON** — the whole source parses as exactly one JSON value within the
33//!   JSON caps (Phase 21.5.1). JSON is a Wave-2 structured-tree format with no
34//!   package layer, so it is detected directly (never via `detect_zip_family`);
35//!   a malformed or oversized input stays `Opaque`.
36//!
37//! The detected format is recorded in the field manifest's provenance (a
38//! machine-readable `format=<name>;` prefix, see [`DocumentFormat::from_provenance`]),
39//! so `observe`/`find`/`explain` can dispatch common selectors without reading the
40//! whole source again.
41
42use crate::limits::Limits;
43
44/// The mandatory OCF `mimetype` payload.
45pub const EPUB_MIMETYPE: &[u8] = b"application/epub+zip";
46/// The mandatory OCF container descriptor member.
47pub const CONTAINER_MEMBER: &[u8] = b"META-INF/container.xml";
48/// The OCF container-descriptor namespace.
49pub const CONTAINER_NS: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:container";
50/// The default (and only normative) package-document media type.
51pub const OPF_MEDIA_TYPE: &[u8] = b"application/oebps-package+xml";
52/// The OPC content-types part.
53#[cfg(feature = "package")]
54const CONTENT_TYPES_MEMBER: &[u8] = b"[Content_Types].xml";
55/// The OPC package-relationships part.
56#[cfg(feature = "package")]
57const PACKAGE_RELS_MEMBER: &[u8] = b"_rels/.rels";
58/// The `officeDocument` relationship type fragment (transitional and strict).
59#[cfg(feature = "package")]
60const OFFICE_DOCUMENT_FRAGMENT: &[u8] = b"officeDocument";
61/// The WordprocessingML document main content-type fragment (Phase 21.1.2).
62#[cfg(feature = "package")]
63const DOCX_MAIN_FRAGMENT: &[u8] = b"wordprocessingml.document.main+xml";
64/// The canonical WordprocessingML main-part target fragment (Phase 21.1.2).
65#[cfg(feature = "package")]
66const DOCX_MAIN_TARGET: &[u8] = b"word/document.xml";
67/// The ODF package manifest member (Phase 13.3).
68#[cfg(feature = "odt")]
69const ODT_MANIFEST_MEMBER: &[u8] = b"META-INF/manifest.xml";
70/// The OpenDocument *text* media-type fragment (Phase 13.3).
71#[cfg(feature = "odt")]
72const ODT_TEXT_FRAGMENT: &[u8] = b"application/vnd.oasis.opendocument.text";
73/// The ODF package manifest member for the ODS detection rule (Phase 21.3.1).
74#[cfg(feature = "ods")]
75const ODS_MANIFEST_MEMBER: &[u8] = b"META-INF/manifest.xml";
76/// The OpenDocument *spreadsheet* media-type fragment (Phase 21.3.1).
77#[cfg(feature = "ods")]
78const ODS_SPREADSHEET_FRAGMENT: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet";
79/// The ODF package manifest member for the ODP detection rule (Phase 21.4.1).
80#[cfg(feature = "odp")]
81const ODP_MANIFEST_MEMBER: &[u8] = b"META-INF/manifest.xml";
82/// The OpenDocument *presentation* media-type fragment (Phase 21.4.1).
83#[cfg(feature = "odp")]
84const ODP_PRESENTATION_FRAGMENT: &[u8] = b"application/vnd.oasis.opendocument.presentation";
85/// The SpreadsheetML workbook main content-type fragment (Phase 21.1.1).
86#[cfg(feature = "xlsx")]
87const XLSX_MAIN_FRAGMENT: &[u8] = b"spreadsheetml.sheet.main+xml";
88/// The SpreadsheetML content-type namespace fragment (Phase 21.1.1).
89#[cfg(feature = "xlsx")]
90const XLSX_NS_FRAGMENT: &[u8] = b"spreadsheetml";
91/// A SpreadsheetML workbook part-target fragment (Phase 21.1.1).
92#[cfg(feature = "xlsx")]
93const XLSX_WORKBOOK_TARGET: &[u8] = b"xl/workbook.xml";
94/// The PresentationML presentation main content-type fragment (Phase 21.2.1).
95#[cfg(feature = "pptx")]
96const PPTX_MAIN_FRAGMENT: &[u8] = b"presentationml.presentation.main+xml";
97/// The PresentationML content-type namespace fragment (Phase 21.2.1).
98#[cfg(feature = "pptx")]
99const PPTX_NS_FRAGMENT: &[u8] = b"presentationml";
100/// The canonical PresentationML main-part target fragment (Phase 21.2.1).
101#[cfg(feature = "pptx")]
102const PPTX_MAIN_TARGET: &[u8] = b"ppt/presentation.xml";
103
104/// A detected document format (the class of the field's source bytes).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum DocumentFormat {
107    /// A validated PDF (physical indicators).
108    Pdf,
109    /// An OPC package with a WordprocessingML `officeDocument` part.
110    Docx,
111    /// An OCF container with an EPUB package document.
112    Epub,
113    /// An ODF package with an OpenDocument text content part.
114    Odt,
115    /// An ODF package with an OpenDocument spreadsheet content part.
116    Ods,
117    /// An ODF package with an OpenDocument presentation content part.
118    Odp,
119    /// An OPC package with a SpreadsheetML workbook part.
120    Xlsx,
121    /// An OPC package with a PresentationML presentation part.
122    Pptx,
123    /// A structured JSON document (the whole source parses as exactly one JSON
124    /// value). Not a package: the exact leaf is the whole source (Phase 21.5.1).
125    Json,
126    /// A structured YAML document (the whole source parses as a bounded YAML
127    /// stream whose every document root is a mapping or a sequence). Not a package:
128    /// the exact leaf is the whole source (Phase 21.6.1).
129    Yaml,
130    /// A CSV/TSV table (the whole source parses under a comma or tab delimiter as a
131    /// table with a consistent field count across a sampled majority of at least
132    /// two records). Not a package: the exact leaf is the whole source, and the
133    /// recorded dialect distinguishes comma from tab (Phase 21.7.1).
134    Csv,
135    /// Anything else; preserved exactly by the opaque floor.
136    Opaque,
137}
138
139impl DocumentFormat {
140    /// Stable lower-case name (used in JSON and in the manifest provenance token).
141    pub const fn name(self) -> &'static str {
142        match self {
143            DocumentFormat::Pdf => "pdf",
144            DocumentFormat::Docx => "docx",
145            DocumentFormat::Epub => "epub",
146            DocumentFormat::Odt => "odt",
147            DocumentFormat::Ods => "ods",
148            DocumentFormat::Odp => "odp",
149            DocumentFormat::Xlsx => "xlsx",
150            DocumentFormat::Pptx => "pptx",
151            DocumentFormat::Json => "json",
152            DocumentFormat::Yaml => "yaml",
153            DocumentFormat::Csv => "csv",
154            DocumentFormat::Opaque => "opaque",
155        }
156    }
157
158    /// The adapter that serves this format (the observation layer's name for it).
159    pub const fn adapter(self) -> &'static str {
160        match self {
161            DocumentFormat::Pdf => "pdf",
162            DocumentFormat::Docx => "docx",
163            DocumentFormat::Epub => "epub",
164            DocumentFormat::Odt => "odt",
165            DocumentFormat::Ods => "ods",
166            DocumentFormat::Odp => "odp",
167            DocumentFormat::Xlsx => "xlsx",
168            DocumentFormat::Pptx => "pptx",
169            DocumentFormat::Json => "json",
170            DocumentFormat::Yaml => "yaml",
171            DocumentFormat::Csv => "csv",
172            DocumentFormat::Opaque => "opaque",
173        }
174    }
175
176    /// Whether the adapter for this format is compiled into this build.
177    pub const fn compiled(self) -> bool {
178        match self {
179            DocumentFormat::Pdf | DocumentFormat::Opaque => true,
180            DocumentFormat::Docx => cfg!(feature = "docx"),
181            DocumentFormat::Epub => cfg!(feature = "epub"),
182            DocumentFormat::Odt => cfg!(feature = "odt"),
183            DocumentFormat::Ods => cfg!(feature = "ods"),
184            DocumentFormat::Odp => cfg!(feature = "odp"),
185            DocumentFormat::Xlsx => cfg!(feature = "xlsx"),
186            DocumentFormat::Pptx => cfg!(feature = "pptx"),
187            DocumentFormat::Json => cfg!(feature = "json"),
188            DocumentFormat::Yaml => cfg!(feature = "yaml"),
189            DocumentFormat::Csv => cfg!(feature = "csv"),
190        }
191    }
192
193    /// The machine-readable `format=<name>;` provenance prefix recorded at ingest.
194    pub fn provenance_prefix(self) -> String {
195        format!("format={};", self.name())
196    }
197
198    /// Recover the recorded format from a field manifest's provenance string.
199    ///
200    /// Returns `None` for a manifest that does not carry the token (e.g. a field
201    /// written before this subphase); such a field still serves every native
202    /// selector, but common observations decline typed rather than guessing.
203    pub fn from_provenance(provenance: &str) -> Option<DocumentFormat> {
204        let rest = provenance.strip_prefix("format=")?;
205        let name = rest.split(';').next()?;
206        match name {
207            "pdf" => Some(DocumentFormat::Pdf),
208            "docx" => Some(DocumentFormat::Docx),
209            "epub" => Some(DocumentFormat::Epub),
210            "odt" => Some(DocumentFormat::Odt),
211            "ods" => Some(DocumentFormat::Ods),
212            "odp" => Some(DocumentFormat::Odp),
213            "xlsx" => Some(DocumentFormat::Xlsx),
214            "pptx" => Some(DocumentFormat::Pptx),
215            "json" => Some(DocumentFormat::Json),
216            "yaml" => Some(DocumentFormat::Yaml),
217            "csv" => Some(DocumentFormat::Csv),
218            "opaque" => Some(DocumentFormat::Opaque),
219            _ => None,
220        }
221    }
222}
223
224/// Detect the document format of `source` from its bytes alone.
225///
226/// Never consults a file name or extension. A malformed or ambiguous input (or a
227/// format whose adapter is not compiled) falls back to [`DocumentFormat::Opaque`].
228pub fn detect_document_format(source: &[u8], limits: Limits) -> DocumentFormat {
229    if crate::adapter::pdf::detect(source, limits) {
230        return DocumentFormat::Pdf;
231    }
232    #[cfg(feature = "package")]
233    {
234        if let Some(format) = detect_zip_family(source, limits) {
235            return format;
236        }
237    }
238    // JSON is a Wave-2 structured-tree format with **no** package layer, so it is
239    // detected directly from the whole source (never through `detect_zip_family`).
240    // Conservative: the entire source must parse as exactly one JSON value within
241    // the JSON caps, else the input stays Opaque (Phase 21.5.1).
242    #[cfg(feature = "json")]
243    if crate::adapter::json::detect(source, limits) {
244        return DocumentFormat::Json;
245    }
246    // YAML is the second Wave-2 structured-tree format (Phase 21.6.1), also with
247    // **no** package layer. It is detected directly and conservatively: the whole
248    // source must parse as a bounded YAML stream under the YAML caps, and every
249    // document root must be a mapping or a sequence, else the input stays Opaque.
250    #[cfg(feature = "yaml")]
251    if crate::adapter::yaml::detect(source, limits) {
252        return DocumentFormat::Yaml;
253    }
254    // CSV/TSV is the first **tabular** Wave-2 format (Phase 21.7.1). It has no
255    // magic bytes, so it is detected last and conservatively: only after the
256    // PDF/ZIP/JSON/YAML families are declined does a source qualify, and then only
257    // if it parses under a specific delimiter (`,` or tab) as a table with a
258    // consistent field count across a sampled majority of records and at least two
259    // columns. When in doubt the input stays Opaque.
260    #[cfg(feature = "csv")]
261    if crate::adapter::csv::detect(source, limits) {
262        return DocumentFormat::Csv;
263    }
264    DocumentFormat::Opaque
265}
266
267/// Whether `source` is a structurally valid ZIP archive.
268///
269/// Used by the universal ingest dispatcher so a generic ZIP (which detection
270/// reports as `Opaque`) is still inverted through the byte-authoritative package
271/// layer rather than the opaque floor.
272#[cfg(feature = "package")]
273pub fn is_zip(source: &[u8], limits: Limits) -> bool {
274    crate::adapter::package::scan(source, limits).is_ok()
275}
276
277#[cfg(feature = "package")]
278fn detect_zip_family(source: &[u8], limits: Limits) -> Option<DocumentFormat> {
279    let physical = crate::adapter::package::scan(source, limits).ok()?;
280
281    // EPUB (OCF): the mandatory `mimetype` member, or an OCF container that
282    // resolves to an OPF rootfile.
283    let mimetype = member_decoded(&physical, source, EPUB_MIMETYPE_MEMBER, limits);
284    let mimetype_ok = mimetype.as_deref() == Some(EPUB_MIMETYPE);
285    let container = member_decoded(&physical, source, CONTAINER_MEMBER, limits);
286    let container_ok = container
287        .as_deref()
288        .is_some_and(|c| contains(c, CONTAINER_NS) && contains(c, OPF_MEDIA_TYPE));
289    let is_epub = mimetype_ok || container_ok;
290
291    // DOCX: an OPC package whose content types declare a WordprocessingML main
292    // part, or whose package relationships declare an `officeDocument` part that
293    // targets `word/document.xml`. The positive WordprocessingML signal (rather
294    // than the mere absence of a SpreadsheetML one) keeps DOCX and XLSX mutually
295    // exclusive without misclassifying a Word document that *embeds* an Excel
296    // workbook (whose package declares SpreadsheetML content types for the
297    // embedded part, but no SpreadsheetML workbook main part). Without `xlsx`
298    // and `pptx` the legacy relationship-only rule stands.
299    let content_types = member_decoded(&physical, source, CONTENT_TYPES_MEMBER, limits);
300    let rels = member_decoded(&physical, source, PACKAGE_RELS_MEMBER, limits);
301    #[cfg(any(feature = "xlsx", feature = "pptx"))]
302    let is_docx = content_types
303        .as_deref()
304        .is_some_and(|ct| contains(ct, DOCX_MAIN_FRAGMENT))
305        || rels.as_deref().is_some_and(|r| {
306            contains(r, OFFICE_DOCUMENT_FRAGMENT) && contains(r, DOCX_MAIN_TARGET)
307        });
308    #[cfg(not(any(feature = "xlsx", feature = "pptx")))]
309    let is_docx = content_types.is_some()
310        && rels
311            .as_deref()
312            .is_some_and(|r| contains(r, OFFICE_DOCUMENT_FRAGMENT));
313
314    // XLSX: an OPC package whose content types declare a SpreadsheetML workbook
315    // (or whose `officeDocument` relationship targets a workbook part).
316    #[cfg(feature = "xlsx")]
317    let is_xlsx = content_types.as_deref().is_some_and(|ct| {
318        contains(ct, XLSX_MAIN_FRAGMENT)
319            || (contains(ct, XLSX_NS_FRAGMENT)
320                && rels
321                    .as_deref()
322                    .is_some_and(|r| contains(r, XLSX_WORKBOOK_TARGET)))
323    });
324    #[cfg(not(feature = "xlsx"))]
325    let is_xlsx = false;
326
327    // PPTX: an OPC package whose content types declare a PresentationML main part
328    // (or whose content types name PresentationML and whose `officeDocument`
329    // relationship targets `ppt/presentation.xml`). The positive PresentationML
330    // signal keeps it mutually exclusive with DOCX and XLSX: a Word/Excel document
331    // that *embeds* a PowerPoint part declares only the PresentationML embed type
332    // (`…presentationml.presentation`, not the `.main+xml` main part) and its
333    // `officeDocument` relationship targets `word/document.xml`/`xl/workbook.xml`,
334    // so it is never misclassified as PPTX.
335    #[cfg(feature = "pptx")]
336    let is_pptx = content_types.as_deref().is_some_and(|ct| {
337        contains(ct, PPTX_MAIN_FRAGMENT)
338            || (contains(ct, PPTX_NS_FRAGMENT)
339                && rels
340                    .as_deref()
341                    .is_some_and(|r| contains(r, PPTX_MAIN_TARGET)))
342    });
343    #[cfg(not(feature = "pptx"))]
344    let is_pptx = false;
345
346    // ODT: an ODF package whose mandatory `mimetype` (or `META-INF/manifest.xml`)
347    // declares an OpenDocument text media type.
348    #[cfg(feature = "odt")]
349    let is_odt = mimetype
350        .as_deref()
351        .is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT))
352        || member_decoded(&physical, source, ODT_MANIFEST_MEMBER, limits)
353            .as_deref()
354            .is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT));
355    #[cfg(not(feature = "odt"))]
356    let is_odt = false;
357
358    // ODS: an ODF package whose mandatory `mimetype` (or `META-INF/manifest.xml`)
359    // declares an OpenDocument *spreadsheet* media type. The positive spreadsheet
360    // fragment keeps it mutually exclusive with ODT (a text document declares the
361    // text media type, never the spreadsheet one).
362    #[cfg(feature = "ods")]
363    let is_ods = mimetype
364        .as_deref()
365        .is_some_and(|m| contains(m, ODS_SPREADSHEET_FRAGMENT))
366        || member_decoded(&physical, source, ODS_MANIFEST_MEMBER, limits)
367            .as_deref()
368            .is_some_and(|m| contains(m, ODS_SPREADSHEET_FRAGMENT));
369    #[cfg(not(feature = "ods"))]
370    let is_ods = false;
371
372    // ODP: an ODF package whose mandatory `mimetype` (or `META-INF/manifest.xml`)
373    // declares an OpenDocument *presentation* media type. The positive presentation
374    // fragment keeps it mutually exclusive with ODT/ODS.
375    #[cfg(feature = "odp")]
376    let is_odp = mimetype
377        .as_deref()
378        .is_some_and(|m| contains(m, ODP_PRESENTATION_FRAGMENT))
379        || member_decoded(&physical, source, ODP_MANIFEST_MEMBER, limits)
380            .as_deref()
381            .is_some_and(|m| contains(m, ODP_PRESENTATION_FRAGMENT));
382    #[cfg(not(feature = "odp"))]
383    let is_odp = false;
384
385    // A ZIP matching more than one native signature is ambiguous: fail safe.
386    let matches = [is_docx, is_epub, is_odt, is_ods, is_odp, is_xlsx, is_pptx]
387        .iter()
388        .filter(|b| **b)
389        .count();
390    match matches {
391        1 if is_docx => Some(DocumentFormat::Docx),
392        1 if is_epub => Some(DocumentFormat::Epub),
393        1 if is_odt => Some(DocumentFormat::Odt),
394        1 if is_ods => Some(DocumentFormat::Ods),
395        1 if is_odp => Some(DocumentFormat::Odp),
396        1 if is_xlsx => Some(DocumentFormat::Xlsx),
397        1 if is_pptx => Some(DocumentFormat::Pptx),
398        _ => None,
399    }
400}
401
402#[cfg(feature = "package")]
403const EPUB_MIMETYPE_MEMBER: &[u8] = b"mimetype";
404
405/// Decode one member's bytes by exact name, bounded and decline-safe: encrypted,
406/// oversized, unsupported-method, or out-of-range members yield `None`.
407#[cfg(feature = "package")]
408fn member_decoded(
409    physical: &crate::adapter::package::ZipPhysical,
410    source: &[u8],
411    name: &[u8],
412    limits: Limits,
413) -> Option<Vec<u8>> {
414    const FLAG_ENCRYPTED: u16 = 0x0001;
415    let member = physical.members.iter().find(|m| m.name == name)?;
416    if member.flags & FLAG_ENCRYPTED != 0 || member.uncompressed_size > limits.max_xml_part_bytes {
417        return None;
418    }
419    let off = usize::try_from(member.data.0).ok()?;
420    let len = usize::try_from(member.data.1).ok()?;
421    let raw = source.get(off..off.checked_add(len)?)?;
422    match member.method {
423        0 => Some(raw.to_vec()),
424        8 => crate::field::derive::inflate_raw_deflate(raw, member.uncompressed_size, limits).ok(),
425        _ => None,
426    }
427}
428
429/// Byte-substring search (no allocation, case-sensitive).
430#[cfg(feature = "package")]
431fn contains(haystack: &[u8], needle: &[u8]) -> bool {
432    !needle.is_empty()
433        && needle.len() <= haystack.len()
434        && haystack.windows(needle.len()).any(|w| w == needle)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn names_and_provenance_roundtrip() {
443        for f in [
444            DocumentFormat::Pdf,
445            DocumentFormat::Docx,
446            DocumentFormat::Epub,
447            DocumentFormat::Odt,
448            DocumentFormat::Ods,
449            DocumentFormat::Odp,
450            DocumentFormat::Xlsx,
451            DocumentFormat::Pptx,
452            DocumentFormat::Json,
453            DocumentFormat::Yaml,
454            DocumentFormat::Csv,
455            DocumentFormat::Opaque,
456        ] {
457            let token = format!("{}field:package;members=1", f.provenance_prefix());
458            assert_eq!(DocumentFormat::from_provenance(&token), Some(f));
459        }
460        assert_eq!(DocumentFormat::from_provenance("field:ingest-b"), None);
461        assert_eq!(DocumentFormat::from_provenance("format=exotic;x"), None);
462    }
463
464    #[test]
465    fn plain_bytes_are_opaque() {
466        assert_eq!(
467            detect_document_format(b"not a document", Limits::DEFAULT),
468            DocumentFormat::Opaque
469        );
470    }
471
472    #[test]
473    fn a_corpus_pdf_is_detected() {
474        let (_, pdf) = crate::adapter::pdf::sample_pdfs()
475            .into_iter()
476            .next()
477            .expect("the PDF corpus is non-empty");
478        assert_eq!(
479            detect_document_format(&pdf, Limits::DEFAULT),
480            DocumentFormat::Pdf
481        );
482    }
483}