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 ODF media-type prefix shared by every OpenDocument package.
86#[cfg(any(feature = "odt", feature = "ods", feature = "odp"))]
87const ODF_MEDIA_PREFIX: &[u8] = b"application/vnd.oasis.opendocument.";
88/// The SpreadsheetML workbook main content-type fragment (Phase 21.1.1).
89#[cfg(feature = "xlsx")]
90const XLSX_MAIN_FRAGMENT: &[u8] = b"spreadsheetml.sheet.main+xml";
91/// The SpreadsheetML content-type namespace fragment (Phase 21.1.1).
92#[cfg(feature = "xlsx")]
93const XLSX_NS_FRAGMENT: &[u8] = b"spreadsheetml";
94/// A SpreadsheetML workbook part-target fragment (Phase 21.1.1).
95#[cfg(feature = "xlsx")]
96const XLSX_WORKBOOK_TARGET: &[u8] = b"xl/workbook.xml";
97/// The PresentationML presentation main content-type fragment (Phase 21.2.1).
98#[cfg(feature = "pptx")]
99const PPTX_MAIN_FRAGMENT: &[u8] = b"presentationml.presentation.main+xml";
100/// The PresentationML content-type namespace fragment (Phase 21.2.1).
101#[cfg(feature = "pptx")]
102const PPTX_NS_FRAGMENT: &[u8] = b"presentationml";
103/// The canonical PresentationML main-part target fragment (Phase 21.2.1).
104#[cfg(feature = "pptx")]
105const PPTX_MAIN_TARGET: &[u8] = b"ppt/presentation.xml";
106
107/// A detected document format (the class of the field's source bytes).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum DocumentFormat {
110    /// A validated PDF (physical indicators).
111    Pdf,
112    /// An OPC package with a WordprocessingML `officeDocument` part.
113    Docx,
114    /// An OCF container with an EPUB package document.
115    Epub,
116    /// An ODF package with an OpenDocument text content part.
117    Odt,
118    /// An ODF package with an OpenDocument spreadsheet content part.
119    Ods,
120    /// An ODF package with an OpenDocument presentation content part.
121    Odp,
122    /// An OPC package with a SpreadsheetML workbook part.
123    Xlsx,
124    /// An OPC package with a PresentationML presentation part.
125    Pptx,
126    /// A structured JSON document (the whole source parses as exactly one JSON
127    /// value). Not a package: the exact leaf is the whole source (Phase 21.5.1).
128    Json,
129    /// A structured JSON5 / JSONC document (the whole source parses as exactly one
130    /// JSON5 value and uses at least one JSON5/JSONC-only construct — a comment, a
131    /// trailing comma, an unquoted key, a single-quoted string, a hex/leading-dot/
132    /// `Infinity`/`NaN` number, a string continuation, or the extended whitespace
133    /// set). A source that is **strict JSON** stays [`DocumentFormat::Json`]; the
134    /// recorded dialect distinguishes `jsonc` (comments/trailing commas only) from
135    /// `json5`. Not a package: the exact leaf is the whole source (Phase 21.17.1).
136    Json5,
137    /// A structured YAML document (the whole source parses as a bounded YAML
138    /// stream whose every document root is a mapping or a sequence). Not a package:
139    /// the exact leaf is the whole source (Phase 21.6.1).
140    Yaml,
141    /// A CSV/TSV table (the whole source parses under a comma or tab delimiter as a
142    /// table with a consistent field count across a sampled majority of at least
143    /// two records). Not a package: the exact leaf is the whole source, and the
144    /// recorded dialect distinguishes comma from tab (Phase 21.7.1).
145    Csv,
146    /// A Markdown prose document (the whole source carries at least one structural
147    /// mark — an ATX heading, a fenced code block, front matter, a table, a
148    /// reference definition, or a footnote definition). Not a package: the exact
149    /// leaf is the whole source (Phase 21.8.1).
150    Markdown,
151    /// A standalone XML document (the whole source begins with `<` and parses as
152    /// well-formed XML with exactly one root element under the XML caps). Not a
153    /// package: the exact leaf is the whole source, and every node/attribute span
154    /// is a `Q_gen` projection (Phase 21.9).
155    Xml,
156    /// A standalone HTML document (the source is not well-formed XML and carries
157    /// clear HTML structure — a `<!doctype html>`, an `<html`/`<head`/`<body` tag,
158    /// or a preponderance of known HTML tags). Parsed by a bounded,
159    /// **error-recovering** scanner. Not a package: the exact leaf is the whole
160    /// source, and every element/attribute/text/comment/raw-text span is a `Q_gen`
161    /// projection (Phase 21.10).
162    Html,
163    /// A TOML document (the source is not any earlier format, parses as TOML 1.0
164    /// under the TOML caps with no errors, and carries at least one key/value
165    /// assignment). Not a package: the exact leaf is the whole source, and every
166    /// table/array/key/value/comment span is a `Q_gen` projection. TOML's
167    /// duplicate-key/redefinition rules are enforced (a violation is a typed
168    /// decline) (Phase 21.11).
169    Toml,
170    /// A JSONL / NDJSON line/event stream (the source carries at least two non-blank
171    /// lines and **every** non-blank line parses as exactly one JSON value under the
172    /// caps, each line parsed by the shared JSON parser). Not a package: the exact
173    /// leaf is the whole source, and every record's exact line span, terminator, and
174    /// per-token spans are `Q_gen` projections. A single JSON value stays
175    /// [`DocumentFormat::Json`] (Phase 21.12).
176    Jsonl,
177    /// An EML / MIME internet message (an RFC 5322 header block terminated by a
178    /// blank line, carrying `From`/`Date`/`Message-ID`, or an explicit
179    /// `MIME-Version`). Not a package: the exact leaf is the whole source, and every
180    /// header/part span and every `Content-Transfer-Encoding`-decoded constituent is
181    /// a `Q_gen` projection (Phase 21.13).
182    Eml,
183    /// An Apache Parquet file (the source begins with `PAR1` and ends with `PAR1`,
184    /// and the 4-byte little-endian footer length before the trailing magic is
185    /// consistent with the file length). Not a package: the exact leaf is the whole
186    /// source, and the parsed footer inventory (schema, row groups, column chunks,
187    /// statistics) and any decoded values are `Q_gen` projections (Phase 21.14).
188    Parquet,
189    /// An Apache Arrow IPC file/stream (the source begins with the `ARROW1` magic
190    /// and is either the **file** format — a trailing `ARROW1` magic preceded by a
191    /// consistent little-endian `int32` footer length — or the **stream** format —
192    /// a valid encapsulated `Schema` message at the 8-byte magic+padding prefix).
193    /// Not a package: the exact leaf is the whole source, and the parsed schema and
194    /// record-batch inventory (with each batch's exact source span) and any decoded
195    /// columnar values are `Q_gen` projections (Phase 21.16).
196    ArrowIpc,
197    /// A CBOR (RFC 8949) structured-tree document. CBOR has **no magic bytes**, so
198    /// detection is deliberately conservative: either the self-described-CBOR tag
199    /// `55799` (`0xd9 0xd9 0xf7`) at the start of a source that then parses, in full,
200    /// as exactly one well-formed CBOR item; or a full-input well-formed parse whose
201    /// root is a container (array/map) or a tag and which reaches at least three
202    /// nodes (so a single scalar, an empty container, and any structurally trivial
203    /// input stay `Opaque`). A container head byte is always `>= 0x80`, so a
204    /// pure-ASCII text document is never claimed; and the whole-number/short-
205    /// container prefix overlaps MessagePack's fixint/fixarray encodings, so an
206    /// ambiguous or trivial input stays `Opaque` rather than being guessed
207    /// (recorded honestly; a Phase-21.19 MessagePack adapter must share the seam).
208    /// Not a package: the exact leaf is the whole source, and every item's kind,
209    /// exact span, encoding width, tag number, float width, and definite/indefinite
210    /// form are `Q_gen` projections (Phase 21.18).
211    Cbor,
212    /// A MessagePack structured-tree document. MessagePack has **no magic bytes**, so
213    /// detection is deliberately conservative: a full-input well-formed parse of
214    /// exactly one item whose root is a container (array/map) reaching a node/byte
215    /// threshold, or the same with an unambiguous MessagePack-only head byte
216    /// (`0xdc..=0xdf`, which CBOR's grammar rejects). A lone scalar, an empty
217    /// container, and any structurally trivial or ambiguous input stay `Opaque`. A
218    /// container head byte is always `>= 0x80`, so a pure-ASCII text document is never
219    /// claimed; and the whole-number/short-container prefix overlaps CBOR's
220    /// `fixint`/short containers, so CBOR (tried first) owns any input well-formed
221    /// under both. Not a package: the exact leaf is the whole source, and every item's
222    /// kind, exact span, exact format byte (encoding width and signedness), `str` vs
223    /// `bin`, float width, and extension type/length are `Q_gen` projections
224    /// (Phase 21.19).
225    Msgpack,
226    /// A config-family document (INI, `.env`, or Java `.properties`). The whole
227    /// source is a bounded, line-based key/value document; detection is
228    /// deliberately conservative and requires a **dialect-distinguishing signal**,
229    /// so the pure `KEY=VALUE` overlap between `env` and `properties` (and plain
230    /// prose, and a `#!` script) stays `Opaque` rather than being guessed. Not a
231    /// package: the exact leaf is the whole source, the recorded dialect lives in
232    /// the model, and every line's content span, key/separator/value span, quoting,
233    /// inline-comment, `export`, continuation, and `\uXXXX`-spelling fact is a
234    /// `Q_gen` projection (Phase 21.20).
235    Config,
236    /// An RSS 2.0 / Atom 1.0 syndication feed. A feed is XML, so its physical
237    /// bytes are shared with [`DocumentFormat::Xml`]; the recorded dialect
238    /// (`rss`/`atom`) lives in the model, exactly as CSV/config record their
239    /// dialects. Detection is a **bounded semantic test** run before the generic
240    /// XML detector: an RSS root `<rss>` with a `<channel>` child, or an Atom root
241    /// `<feed>` in the Atom namespace (`http://www.w3.org/2005/Atom`) with at least
242    /// one `<entry>` child. Not a package: the exact leaf is the whole source, and
243    /// every element/attribute span, element order, attribute spelling, and the
244    /// namespace declaration is a `Q_gen` projection (Phase 21.21).
245    Feed,
246    /// A GeoJSON (RFC 7946) document. GeoJSON is JSON, so its physical bytes are
247    /// shared with [`DocumentFormat::Json`]; the recorded root class (one of the nine
248    /// RFC 7946 type names) lives in the model, exactly as a feed records its
249    /// dialect. Detection is a **bounded semantic test** run before the generic JSON
250    /// detector: the source parses as exactly one JSON value whose root is an object
251    /// with a `"type"` string equal to one of the nine type names and whose shape is
252    /// consistent (a geometry has an array `coordinates`/`geometries`, a `Feature` has
253    /// `geometry`/`properties`, a `FeatureCollection` has an array `features`). A plain
254    /// JSON document, and a JSON document whose `"type"` is an unrelated string, stay
255    /// [`DocumentFormat::Json`]. Not a package: the exact leaf is the whole source, and
256    /// every JSON token span, `coordinates` nesting, `properties` order/duplicates, and
257    /// foreign member is a `Q_gen` projection (Phase 21.22).
258    Geojson,
259    /// A KML 2.2 / GPX 1.1 geospatial document. KML and GPX are XML, so their
260    /// physical bytes are shared with [`DocumentFormat::Xml`]; the recorded dialect
261    /// (`kml`/`gpx`) lives in the model, exactly as a feed records its dialect.
262    /// Detection is a **bounded semantic test** run before the generic XML detector:
263    /// a `<kml>` root in the KML namespace (`http://www.opengis.net/kml/2.2`) with a
264    /// `Document`/`Folder`/`Placemark` child, or a `<gpx>` root in the GPX namespace
265    /// (`http://www.topografix.com/GPX/1/1`) with a `metadata`/`wpt`/`rte`/`trk`
266    /// child. A plain XML document, and a shaped-but-invalid KML/GPX, stay
267    /// [`DocumentFormat::Xml`]. Not a package: the exact leaf is the whole source,
268    /// and every element/attribute span, element order, attribute spelling (KML
269    /// geometry `coordinates`, GPX `lat`/`lon`), and the namespace declaration is a
270    /// `Q_gen` projection (Phase 21.23).
271    Gis,
272    /// A Jupyter notebook (`.ipynb`, nbformat) document. A notebook's physical bytes
273    /// are JSON, so its bytes are shared with [`DocumentFormat::Json`]; the recorded
274    /// `nbformat`/`nbformat_minor` and the cell anchors live in the model, exactly as a
275    /// feed records its dialect. Detection is a **bounded semantic test** run before the
276    /// generic JSON detector: the source parses as exactly one JSON value whose root is
277    /// an object with a plain non-negative integer-literal `nbformat` (≥ 1) and an array
278    /// `cells`, every cell an object with a string `cell_type`, and every present
279    /// recognized field nbformat-shaped. A plain JSON document, and a JSON document that
280    /// merely has a `cells` key but is not nbformat-shaped, stay
281    /// [`DocumentFormat::Json`]. Not a package: the exact leaf is the whole source, and
282    /// every JSON token span, the exact `cell_type`/`output_type` strings, the exact
283    /// `source` representation, and the cell/output order are `Q_gen` projections
284    /// (Phase 21.24).
285    Notebook,
286    /// A fixed-width (column-position) text table. Unlike CSV/TSV/PSV, its columns
287    /// are defined by **character positions**, not a delimiter, so it is a distinct
288    /// format rather than a CSV dialect. Detection is **maximally conservative**
289    /// (fixed-width is inherently ambiguous): at least three sampled records of
290    /// identical byte width, an inferred column layout whose interior whitespace gaps
291    /// are at least two columns wide, at least two non-empty columns, and an outright
292    /// decline of anything that also parses as a delimited (CSV/TSV/PSV) or Markdown
293    /// table. Not a package: the exact leaf is the whole source, and the recorded
294    /// inferred layout, per-record spans, per-column spans, padding, terminator, BOM,
295    /// and header row are `Q_gen` projections (Phase 21.25).
296    FixedWidth,
297    /// A reStructuredText (Docutils) prose document (the whole source carries a
298    /// reST-specific structural mark — an explicit markup start (`.. ` comment,
299    /// directive, target, footnote/citation, or substitution definition), a grid or
300    /// simple table, a field list, or a section adornment whose char is not a
301    /// Markdown construct — so plain prose and a Markdown document are never stolen).
302    /// Not a package: the exact leaf is the whole source (Phase 21.26.1).
303    Rst,
304    /// An AsciiDoc prose document (the whole source carries an AsciiDoc-specific
305    /// structural mark — a level-0 document title followed by a further block, a
306    /// `==`+ section, a `|===` table, a complete delimited block, or a block attribute
307    /// line followed by a block — so plain prose and a Markdown or reStructuredText
308    /// document are never stolen). Not a package: the exact leaf is the whole source
309    /// (Phase 21.26.2).
310    Asciidoc,
311    /// An MDX document (Markdown + JSX/ESM). The whole source carries an MDX-specific
312    /// signal — a top-level ESM `import`/`export` statement, a JSX element with a
313    /// capitalized/namespaced component name (or a JSX-specific attribute), a JSX
314    /// fragment, or a whole-line MDX block expression `{…}` — so plain Markdown stays
315    /// `Markdown`, plain HTML stays `Html`, and plain prose stays `Opaque`. Not a
316    /// package: the exact leaf is the whole source (Phase 21.26.3).
317    Mdx,
318    /// An MHTML (MIME HTML, RFC 2557) web archive. MHTML is a MIME
319    /// `multipart/related` document, so its physical bytes are shared with
320    /// [`DocumentFormat::Eml`]; the adapter **reuses the EML adapter's bounded MIME
321    /// layer** for the envelope, parts, headers, and spans, and the **HTML adapter's
322    /// bounded, error-recovering scanner** for the root part, recording the ordered
323    /// sub-resources (keyed by `Content-Location`/`Content-ID`) and the root HTML part
324    /// (the `start=` `Content-ID`, else the first `text/html` part, else the first
325    /// part). Detection is a **bounded semantic sub-detection** run before the generic
326    /// EML detector: a `multipart/related` root plus an MHTML-specific signal — a
327    /// top-level `Snapshot-Content-Location`/`Content-Base`, or a `From`+`Subject`
328    /// envelope with a `text/html` part. A plain `multipart/related` email, a plain
329    /// HTML document, and a plain MIME message stay `Eml`/`Html`/`Eml`. Not a package:
330    /// the exact leaf is the whole source, and every envelope/part/resource/HTML span
331    /// is a `Q_gen` projection (Phase 21.27).
332    Mhtml,
333    /// A syslog / log-stream document (the source carries at least two non-blank
334    /// lines and **every** non-blank line matches one of three dialects — RFC 5424
335    /// syslog, RFC 3164 (BSD) syslog, or a generic application log line with a
336    /// leading timestamp and/or level token). The recorded dialect lives per record.
337    /// Detection is byte-based and conservative and must not steal a higher-priority
338    /// format (JSON/JSON5/JSONL/CBOR/Msgpack/YAML/TOML/config/CSV or the prose
339    /// family), all of which the whole-source dispatcher tries first; plain prose and
340    /// code stay `Opaque`. Not a package: the exact leaf is the whole source, and
341    /// every record's exact line span/terminator plus every field's exact span (and
342    /// the preserved message, NILVALUE, and timestamp/level spelling) are `Q_gen`
343    /// projections (Phase 21.28).
344    Logstream,
345    /// A package-metadata manifest (an npm `package.json` / `package-lock.json`, a
346    /// Cargo `Cargo.toml` / `Cargo.lock`, or a Python `pyproject.toml`). A manifest's
347    /// physical bytes are JSON or TOML, so its bytes are shared with
348    /// [`DocumentFormat::Json`]/[`DocumentFormat::Toml`]; the **recorded dialect**
349    /// (`npm_package` / `cargo_manifest` / `pyproject_manifest` / `cargo_lock` /
350    /// `npm_lock`) lives in the model, exactly as a feed or CSV records its dialect.
351    /// Detection is a **bounded semantic test** run before the generic JSON and TOML
352    /// detectors: a strong manifest shape (a JSON object with string `name`+`version`
353    /// and a third package-specific key, a JSON `lockfileVersion`+`packages`, a TOML
354    /// `[package]` table with a string `name`, a TOML `[build-system]`/`[project]`/
355    /// `[tool.poetry]`, or a TOML `[[package]]` lockfile shape). A generic JSON
356    /// `name`+`version`, a generic TOML `name`/`version`, and plain prose stay
357    /// `Json`/`Toml`/`Opaque`. Not a package: the exact leaf is the whole source, and
358    /// every reused-arena token span plus every recorded section/entry name/value span
359    /// is a `Q_gen` projection (Phase 21.29).
360    Pkgmeta,
361    /// An API/specification document (a JSON Schema, an OpenAPI 3.x, a Swagger 2.0, or
362    /// an AsyncAPI document). An API spec's physical bytes are JSON, so its bytes are
363    /// shared with [`DocumentFormat::Json`]; the **recorded dialect** (`json_schema` /
364    /// `openapi` / `swagger` / `asyncapi`) lives in the model, exactly as CSV/config/
365    /// feed/pkgmeta record their dialects. Detection is a **bounded semantic test** run
366    /// before the generic JSON detector on a **strong, root-level string marker**: a
367    /// JSON-Schema `$schema` URI, an `openapi` `3.`-prefixed string, a `swagger` equal
368    /// to `"2.0"`, or a non-empty `asyncapi` string. A plain JSON object that merely
369    /// contains a `properties`/`paths`/`components` key but lacks the marker stays
370    /// [`DocumentFormat::Json`], and a JSON-Schema-shaped object with no `$schema` is
371    /// an honest recorded ambiguity that also stays `Json`. Not a package: the exact
372    /// leaf is the whole source, and every JSON token span plus every recorded
373    /// object/member/`$ref` span is a `Q_gen` projection — `$ref` targets are preserved
374    /// verbatim and never resolved (Phase 21.30).
375    Apispec,
376    /// A bibliography document — **BibTeX** (`.bib`), **RIS**, or **CSL-JSON**. One
377    /// adapter covers the three dialects and records the dialect in the model, exactly
378    /// as CSV/config/feed/pkgmeta/apispec record theirs. BibTeX and RIS are text (no
379    /// magic bytes); CSL-JSON's physical bytes are JSON, so its **bounded semantic
380    /// sub-detection runs before the generic JSON detector**: the root must be an
381    /// **array** of objects (or a single object) each carrying a string `id` **and** a
382    /// string `type`. A plain JSON array, a JSON array of non-CSL objects, a generic
383    /// JSON object, and prose stay [`DocumentFormat::Json`]/[`DocumentFormat::Opaque`].
384    /// Not a package: the exact leaf is the whole source, and every entry/field/item
385    /// token span (and each value's exact spelling and delimiters) is a `Q_gen`
386    /// projection (Phase 21.31).
387    Bib,
388    /// An RTF (Rich Text Format) document. RTF is a text, group-brace grammar with
389    /// control words — closer to a bounded tree (like XML) than to a flat line format —
390    /// but it is *not* a package: there is no OPC/ZIP layer, so the exact leaf is the
391    /// whole source (a `DocumentExact`, RAW-like authority) and every derived
392    /// observation is a bounded, span-preserving (`Q_gen`) projection of it. Detection
393    /// is a **strong magic + brace-balance** test: the source must begin (after an
394    /// optional UTF-8 BOM) with `{\rtf` followed by a digit, and its group braces must
395    /// balance under a bounded scan (respecting `\\`/`\{`/`\}`, `\'hh`, control words,
396    /// and `\binN`), with the outermost group closing exactly at end of input. A plain
397    /// text source, a leading-`{` blob, and every other format are therefore never
398    /// stolen; they stay `Opaque`. The adapter preserves the header, every group with
399    /// its exact brace span and nesting, every control word/symbol, every `\'hh` hex
400    /// escape and `\uN` Unicode escape (with its `\ucN` fallback), every destination
401    /// (`\fonttbl`/`\colortbl`/`\stylesheet`/`\info`/`\pict`/`\object`/`\*`) verbatim,
402    /// and every plain text run byte-exactly (never whitespace-normalized) — Phase
403    /// 21.32.
404    Rtf,
405    /// A generic archive container (Phase 21.33): a standalone **ZIP** archive (a
406    /// valid EOCD and a coherent central directory) or a standalone **TAR** archive
407    /// (a valid `ustar`/old-GNU or v7 header whose checksum matches). The recorded
408    /// dialect (`zip`/`tar`) lives in the model, exactly as CSV/config/feed record
409    /// theirs. This is deliberately a **plain archive**, never an office package: the
410    /// ZIP-family office formats (DOCX/EPUB/ODT/ODS/ODP/XLSX/PPTX) are detected
411    /// **before** this adapter, so a `.docx`/`.xlsx`/`.odt` source still detects as
412    /// its office format and is never reclassified. Not a package: the exact leaf is
413    /// the whole source (a `DocumentExact`, RAW-like authority) and every derived
414    /// observation is a bounded, span-preserving (`Q_gen`) projection of it.
415    Container,
416    /// A scientific container (Phase 21.34): a NetCDF classic (CDF-1/CDF-2) file or an
417    /// HDF5 file (a documented bounded subset). The recorded dialect
418    /// (`netcdf`/`hdf5`) lives in the model, exactly as the archive container records
419    /// `zip`/`tar`. This is deliberately a **scan-only scientific format**: it is not
420    /// an office package. Detection is a **strong magic-byte** test — NetCDF requires
421    /// `CDF\x01`/`CDF\x02` at offset 0; HDF5 requires the 8-byte signature
422    /// `\x89HDF\r\n\x1a\n` at offset 0 or 512 with a superblock version in `0..=3`, an
423    /// offset/length size in `{2,4,8}`, and a superblock that fits inside the source.
424    /// Not a package: the exact leaf is the whole source (a `DocumentExact`, RAW-like
425    /// authority) and every derived observation is a bounded, span-preserving (`Q_gen`)
426    /// projection of it. NetCDF classic is supported in full; HDF5 is a documented
427    /// bounded subset (symbol-table groups + contiguous datasets + v1 object headers),
428    /// and everything outside it declines **typed**.
429    Hdf,
430    /// A CFBF / OLE2 compound file (Phase 21.35): the physical-layer container behind
431    /// legacy Word (`.doc`), Excel (`.xls`), PowerPoint (`.ppt`), and Outlook (`.msg`)
432    /// files. Detection is a **strong magic + header validation** test — the 8-byte
433    /// signature `D0 CF 11 E0 A1 B1 1A E1` at offset 0 **and** an internally
434    /// consistent header (byte order `FFFE`, a major version of 3 or 4, a sector shift
435    /// of 9 or 12 consistent with the version, a mini-sector shift of 6, at least one
436    /// FAT sector, and a first directory sector inside the file). A random
437    /// signature-only blob and prose stay `Opaque`. The recorded dialect
438    /// (`cfb3`/`cfb4`) lives in the model. Not a package: the exact leaf is the whole
439    /// source (a `DocumentExact`, RAW-like authority) and every derived observation —
440    /// the header, the DIFAT/FAT, the directory entries with their verbatim UTF-16LE
441    /// names and reconstructed sibling/child tree, and each stream's exact byte span —
442    /// is a bounded, span-preserving (`Q_gen`) projection of it. This is the shared
443    /// physical layer that unlocks the legacy DOC/XLS/PPT/MSG semantic models (later
444    /// subphases).
445    Cfbf,
446    /// A legacy Word 97–2003 document (`.doc`, MS-DOC) (Phase 26.1): a CFBF/OLE2
447    /// compound file whose `WordDocument` stream opens with the Word FIB (magic
448    /// `0xA5EC`) and whose table stream (`0Table`/`1Table` per the FIB `fWhichTblStm`
449    /// flag) carries the piece table. Detection is built **on top of** the CFBF
450    /// detector — the source must be a compound file the CFBF layer accepts, its
451    /// `WordDocument` stream must begin with the FIB magic and a plausible Word
452    /// 97–2003 `nFib`, and its table stream must exist — so a generic compound file
453    /// without a Word FIB stays [`Cfbf`](DocumentFormat::Cfbf) and a non-CFBF source is
454    /// unaffected. The recorded model carries the FIB (`nFib`/version, the
455    /// `fWhichTblStm` flag, `fcMin`/`fcMac`, the `ccp*` section counts, `fcClx`/
456    /// `lcbClx`) and the piece table (each piece's CP range, resolved `fc`, compressed
457    /// flag, exact `WordDocument`-stream byte span, and decoded text). Not a package:
458    /// the exact leaf is the whole source (a `DocumentExact`, RAW-like authority) and
459    /// every derived observation is a bounded, span-preserving (`Q_gen`) projection of
460    /// it. Everything outside the documented subset declines **typed**.
461    Doc,
462    /// A legacy Excel 97–2003 spreadsheet (`.xls`, BIFF8) (Phase 26.2): a CFBF/OLE2
463    /// compound file whose `Workbook` stream (the legacy `Book` name is accepted too)
464    /// opens with a BIFF8 `BOF` (id `0x0809`, version `0x0600`) whose substream type is
465    /// a recognized worksheet/globals type. Detection is built **on top of** the CFBF
466    /// detector — the source must be a compound file the CFBF layer accepts and its
467    /// `Workbook`/`Book` stream must begin with that `BOF` — so a generic compound file
468    /// stays [`Cfbf`](DocumentFormat::Cfbf), a `.doc` stays [`Doc`](DocumentFormat::Doc),
469    /// and a non-CFBF source (including a `.xlsx`, an OPC ZIP) is unaffected. The
470    /// recorded model carries the BIFF version, the record list with exact spans, the
471    /// `BOUNDSHEET` sheets (name, `BOF` stream position, in order), the `SST` shared
472    /// string table (with `CONTINUE` reassembly), and the per-sheet cell records
473    /// (`LABELSST`/`NUMBER`/`RK`/`BOOLERR`/`FORMULA`+`STRING`/`BLANK`/`MULBLANK`) with
474    /// their row/column, exact record span, and exact stored value spelling. Not a
475    /// package: the exact leaf is the whole source (a `DocumentExact`) and every derived
476    /// observation is a bounded, span-preserving (`Q_gen`) projection of it. Everything
477    /// outside the documented subset declines **typed**.
478    Xls,
479    /// A legacy PowerPoint 97–2003 presentation (`.ppt`, MS-PPT) (Phase 26.3): a
480    /// CFBF/OLE2 compound file whose `PowerPoint Document` stream begins with a
481    /// `Document` record (`recVer 0xF`, `recType 1000`). Detection is built **on top
482    /// of** the CFBF detector — the source must be a compound file the CFBF layer
483    /// accepts and its `PowerPoint Document` stream must begin with that `Document`
484    /// record — so a generic compound file stays [`Cfbf`](DocumentFormat::Cfbf), a
485    /// `.doc` stays [`Doc`](DocumentFormat::Doc), a `.xls` stays
486    /// [`Xls`](DocumentFormat::Xls), and a non-CFBF source (including a `.pptx`, an OPC
487    /// ZIP) is unaffected. The recorded model carries the MS-PPT record stream (every
488    /// record's `recVer`/`recInstance`/`recType`/`recLen`, exact span, stream offset,
489    /// and nesting depth), the slides from the `SlideListWithText` containers'
490    /// `SlidePersistAtom` entries (in order, by `persistIdRef`), and the
491    /// `TextCharsAtom` (UTF-16LE) / `TextBytesAtom` (CP1252) runs with their exact
492    /// spans and decoded text; the `Current User` stream's `UserEditAtom` offset is
493    /// recorded when it resolves. Not a package: the exact leaf is the whole source (a
494    /// `DocumentExact`) and every derived observation is a bounded, span-preserving
495    /// (`Q_gen`) projection of it. Everything outside the documented subset declines
496    /// **typed**.
497    Ppt,
498    /// A legacy Outlook message (`.msg`, MS-OXMSG) (Phase 26.4): a CFBF/OLE2 compound
499    /// file whose **root storage** has a `__properties_version1.0` stream. Detection is
500    /// built **on top of** the CFBF detector — the source must be a compound file the
501    /// CFBF layer accepts and must have a root-level `__properties_version1.0` stream —
502    /// so a generic compound file stays [`Cfbf`](DocumentFormat::Cfbf), a `.doc` stays
503    /// [`Doc`](DocumentFormat::Doc), a `.xls` stays [`Xls`](DocumentFormat::Xls), a
504    /// `.ppt` stays [`Ppt`](DocumentFormat::Ppt), and a non-CFBF source is unaffected.
505    /// The recorded model carries the top-level message header (`Reserved`,
506    /// `NextRecipientId`, `NextAttachmentId`, `RecipientCount`, `AttachmentCount`, and
507    /// the trailing `Reserved`), the fixed-size 16-byte property entries (raw tag,
508    /// flags, value bytes, exact span, owner) in order, the variable-size
509    /// `__substg1.0_<PPPPTTTT>` substreams (`0x001F` UTF-16LE and `0x001E` ANSI/CP1252
510    /// decoded, `0x0102` binary kept as exact bytes) with their exact spans, and the
511    /// recipient (`__recip_version1.0_<id>`) / attachment (`__attach_version1.0_<id>`)
512    /// sub-storages with their own property lists and substreams (order and ids
513    /// preserved). Not a package: the exact leaf is the whole source (a
514    /// `DocumentExact`) and every derived observation is a bounded, span-preserving
515    /// (`Q_gen`) projection of it. Everything outside the documented subset declines
516    /// **typed**.
517    Msg,
518    /// Anything else; preserved exactly by the opaque floor.
519    Opaque,
520}
521
522impl DocumentFormat {
523    /// Stable lower-case name (used in JSON and in the manifest provenance token).
524    pub const fn name(self) -> &'static str {
525        match self {
526            DocumentFormat::Pdf => "pdf",
527            DocumentFormat::Docx => "docx",
528            DocumentFormat::Epub => "epub",
529            DocumentFormat::Odt => "odt",
530            DocumentFormat::Ods => "ods",
531            DocumentFormat::Odp => "odp",
532            DocumentFormat::Xlsx => "xlsx",
533            DocumentFormat::Pptx => "pptx",
534            DocumentFormat::Json => "json",
535            DocumentFormat::Json5 => "json5",
536            DocumentFormat::Yaml => "yaml",
537            DocumentFormat::Csv => "csv",
538            DocumentFormat::Markdown => "markdown",
539            DocumentFormat::Xml => "xml",
540            DocumentFormat::Html => "html",
541            DocumentFormat::Toml => "toml",
542            DocumentFormat::Jsonl => "jsonl",
543            DocumentFormat::Eml => "eml",
544            DocumentFormat::Parquet => "parquet",
545            DocumentFormat::ArrowIpc => "arrow",
546            DocumentFormat::Cbor => "cbor",
547            DocumentFormat::Msgpack => "msgpack",
548            DocumentFormat::Config => "config",
549            DocumentFormat::Feed => "feed",
550            DocumentFormat::Geojson => "geojson",
551            DocumentFormat::Gis => "gis",
552            DocumentFormat::Notebook => "notebook",
553            DocumentFormat::FixedWidth => "fixedwidth",
554            DocumentFormat::Rst => "rst",
555            DocumentFormat::Asciidoc => "asciidoc",
556            DocumentFormat::Mdx => "mdx",
557            DocumentFormat::Mhtml => "mhtml",
558            DocumentFormat::Logstream => "logstream",
559            DocumentFormat::Pkgmeta => "pkgmeta",
560            DocumentFormat::Apispec => "apispec",
561            DocumentFormat::Bib => "bib",
562            DocumentFormat::Rtf => "rtf",
563            DocumentFormat::Container => "container",
564            DocumentFormat::Hdf => "hdf",
565            DocumentFormat::Cfbf => "cfbf",
566            DocumentFormat::Doc => "doc",
567            DocumentFormat::Xls => "xls",
568            DocumentFormat::Ppt => "ppt",
569            DocumentFormat::Msg => "msg",
570            DocumentFormat::Opaque => "opaque",
571        }
572    }
573
574    /// The adapter that serves this format (the observation layer's name for it).
575    pub const fn adapter(self) -> &'static str {
576        match self {
577            DocumentFormat::Pdf => "pdf",
578            DocumentFormat::Docx => "docx",
579            DocumentFormat::Epub => "epub",
580            DocumentFormat::Odt => "odt",
581            DocumentFormat::Ods => "ods",
582            DocumentFormat::Odp => "odp",
583            DocumentFormat::Xlsx => "xlsx",
584            DocumentFormat::Pptx => "pptx",
585            DocumentFormat::Json => "json",
586            DocumentFormat::Json5 => "json5",
587            DocumentFormat::Yaml => "yaml",
588            DocumentFormat::Csv => "csv",
589            DocumentFormat::Markdown => "markdown",
590            DocumentFormat::Xml => "xml",
591            DocumentFormat::Html => "html",
592            DocumentFormat::Toml => "toml",
593            DocumentFormat::Jsonl => "jsonl",
594            DocumentFormat::Eml => "eml",
595            DocumentFormat::Parquet => "parquet",
596            DocumentFormat::ArrowIpc => "arrow",
597            DocumentFormat::Cbor => "cbor",
598            DocumentFormat::Msgpack => "msgpack",
599            DocumentFormat::Config => "config",
600            DocumentFormat::Feed => "feed",
601            DocumentFormat::Geojson => "geojson",
602            DocumentFormat::Gis => "gis",
603            DocumentFormat::Notebook => "notebook",
604            DocumentFormat::FixedWidth => "fixedwidth",
605            DocumentFormat::Rst => "rst",
606            DocumentFormat::Asciidoc => "asciidoc",
607            DocumentFormat::Mdx => "mdx",
608            DocumentFormat::Mhtml => "mhtml",
609            DocumentFormat::Logstream => "logstream",
610            DocumentFormat::Pkgmeta => "pkgmeta",
611            DocumentFormat::Apispec => "apispec",
612            DocumentFormat::Bib => "bib",
613            DocumentFormat::Rtf => "rtf",
614            DocumentFormat::Container => "container",
615            DocumentFormat::Hdf => "hdf",
616            DocumentFormat::Cfbf => "cfbf",
617            DocumentFormat::Doc => "doc",
618            DocumentFormat::Xls => "xls",
619            DocumentFormat::Ppt => "ppt",
620            DocumentFormat::Msg => "msg",
621            DocumentFormat::Opaque => "opaque",
622        }
623    }
624
625    /// Whether the adapter for this format is compiled into this build.
626    pub const fn compiled(self) -> bool {
627        match self {
628            DocumentFormat::Pdf | DocumentFormat::Opaque => true,
629            DocumentFormat::Docx => cfg!(feature = "docx"),
630            DocumentFormat::Epub => cfg!(feature = "epub"),
631            DocumentFormat::Odt => cfg!(feature = "odt"),
632            DocumentFormat::Ods => cfg!(feature = "ods"),
633            DocumentFormat::Odp => cfg!(feature = "odp"),
634            DocumentFormat::Xlsx => cfg!(feature = "xlsx"),
635            DocumentFormat::Pptx => cfg!(feature = "pptx"),
636            DocumentFormat::Json => cfg!(feature = "json"),
637            DocumentFormat::Json5 => cfg!(feature = "json5"),
638            DocumentFormat::Yaml => cfg!(feature = "yaml"),
639            DocumentFormat::Csv => cfg!(feature = "csv"),
640            DocumentFormat::Markdown => cfg!(feature = "markdown"),
641            DocumentFormat::Xml => cfg!(feature = "xml"),
642            DocumentFormat::Html => cfg!(feature = "html"),
643            DocumentFormat::Toml => cfg!(feature = "toml"),
644            DocumentFormat::Jsonl => cfg!(feature = "jsonl"),
645            DocumentFormat::Eml => cfg!(feature = "eml"),
646            DocumentFormat::Parquet => cfg!(feature = "parquet"),
647            DocumentFormat::ArrowIpc => cfg!(feature = "arrow"),
648            DocumentFormat::Cbor => cfg!(feature = "cbor"),
649            DocumentFormat::Msgpack => cfg!(feature = "msgpack"),
650            DocumentFormat::Config => cfg!(feature = "config"),
651            DocumentFormat::Feed => cfg!(feature = "feed"),
652            DocumentFormat::Geojson => cfg!(feature = "geojson"),
653            DocumentFormat::Gis => cfg!(feature = "gis"),
654            DocumentFormat::Notebook => cfg!(feature = "notebook"),
655            DocumentFormat::FixedWidth => cfg!(feature = "fixedwidth"),
656            DocumentFormat::Rst => cfg!(feature = "rst"),
657            DocumentFormat::Asciidoc => cfg!(feature = "asciidoc"),
658            DocumentFormat::Mdx => cfg!(feature = "mdx"),
659            DocumentFormat::Mhtml => cfg!(feature = "mhtml"),
660            DocumentFormat::Logstream => cfg!(feature = "logstream"),
661            DocumentFormat::Pkgmeta => cfg!(feature = "pkgmeta"),
662            DocumentFormat::Apispec => cfg!(feature = "apispec"),
663            DocumentFormat::Bib => cfg!(feature = "bib"),
664            DocumentFormat::Rtf => cfg!(feature = "rtf"),
665            DocumentFormat::Container => cfg!(feature = "container"),
666            DocumentFormat::Hdf => cfg!(feature = "hdf"),
667            DocumentFormat::Cfbf => cfg!(feature = "cfbf"),
668            DocumentFormat::Doc => cfg!(feature = "doc"),
669            DocumentFormat::Xls => cfg!(feature = "xls"),
670            DocumentFormat::Ppt => cfg!(feature = "ppt"),
671            DocumentFormat::Msg => cfg!(feature = "msg"),
672        }
673    }
674
675    /// The machine-readable `format=<name>;` provenance prefix recorded at ingest.
676    pub fn provenance_prefix(self) -> String {
677        format!("format={};", self.name())
678    }
679
680    /// Recover the recorded format from a field manifest's provenance string.
681    ///
682    /// Returns `None` for a manifest that does not carry the token (e.g. a field
683    /// written before this subphase); such a field still serves every native
684    /// selector, but common observations decline typed rather than guessing.
685    pub fn from_provenance(provenance: &str) -> Option<DocumentFormat> {
686        let rest = provenance.strip_prefix("format=")?;
687        let name = rest.split(';').next()?;
688        match name {
689            "pdf" => Some(DocumentFormat::Pdf),
690            "docx" => Some(DocumentFormat::Docx),
691            "epub" => Some(DocumentFormat::Epub),
692            "odt" => Some(DocumentFormat::Odt),
693            "ods" => Some(DocumentFormat::Ods),
694            "odp" => Some(DocumentFormat::Odp),
695            "xlsx" => Some(DocumentFormat::Xlsx),
696            "pptx" => Some(DocumentFormat::Pptx),
697            "json" => Some(DocumentFormat::Json),
698            "json5" => Some(DocumentFormat::Json5),
699            "yaml" => Some(DocumentFormat::Yaml),
700            "csv" => Some(DocumentFormat::Csv),
701            "markdown" => Some(DocumentFormat::Markdown),
702            "xml" => Some(DocumentFormat::Xml),
703            "html" => Some(DocumentFormat::Html),
704            "toml" => Some(DocumentFormat::Toml),
705            "jsonl" => Some(DocumentFormat::Jsonl),
706            "eml" => Some(DocumentFormat::Eml),
707            "parquet" => Some(DocumentFormat::Parquet),
708            "arrow" => Some(DocumentFormat::ArrowIpc),
709            "cbor" => Some(DocumentFormat::Cbor),
710            "msgpack" => Some(DocumentFormat::Msgpack),
711            "config" => Some(DocumentFormat::Config),
712            "feed" => Some(DocumentFormat::Feed),
713            "geojson" => Some(DocumentFormat::Geojson),
714            "gis" => Some(DocumentFormat::Gis),
715            "notebook" => Some(DocumentFormat::Notebook),
716            "fixedwidth" => Some(DocumentFormat::FixedWidth),
717            "rst" => Some(DocumentFormat::Rst),
718            "asciidoc" => Some(DocumentFormat::Asciidoc),
719            "mdx" => Some(DocumentFormat::Mdx),
720            "mhtml" => Some(DocumentFormat::Mhtml),
721            "logstream" => Some(DocumentFormat::Logstream),
722            "pkgmeta" => Some(DocumentFormat::Pkgmeta),
723            "apispec" => Some(DocumentFormat::Apispec),
724            "bib" => Some(DocumentFormat::Bib),
725            "rtf" => Some(DocumentFormat::Rtf),
726            "container" => Some(DocumentFormat::Container),
727            "hdf" => Some(DocumentFormat::Hdf),
728            "cfbf" => Some(DocumentFormat::Cfbf),
729            "doc" => Some(DocumentFormat::Doc),
730            "xls" => Some(DocumentFormat::Xls),
731            "ppt" => Some(DocumentFormat::Ppt),
732            "msg" => Some(DocumentFormat::Msg),
733            "opaque" => Some(DocumentFormat::Opaque),
734            _ => None,
735        }
736    }
737}
738
739/// Detect the document format of `source` from its bytes alone.
740///
741/// Never consults a file name or extension. A malformed or ambiguous input (or a
742/// format whose adapter is not compiled) falls back to [`DocumentFormat::Opaque`].
743pub fn detect_document_format(source: &[u8], limits: Limits) -> DocumentFormat {
744    if crate::adapter::pdf::detect(source, limits) {
745        return DocumentFormat::Pdf;
746    }
747    #[cfg(feature = "package")]
748    {
749        if let Some(format) = detect_zip_family(source, limits) {
750            return format;
751        }
752    }
753    // The generic archive container (Phase 21.33) is a **standalone** format, never
754    // an office package: a plain ZIP (a valid EOCD + coherent central directory) or a
755    // plain TAR (a strong 512-byte header whose checksum matches). It is inserted
756    // **after** the entire office ZIP family above — so an office package is never
757    // reclassified as a plain archive — and **before** every weak, no-magic-byte
758    // heuristic (JSON/YAML/CSV/Markdown/XML/HTML), which a binary archive must never
759    // be guessed to be. It is deliberately conservative: a `PK`-prefixed blob without
760    // a valid EOCD, a 512-byte-junk blob whose checksum fails, and prose all stay
761    // `Opaque`.
762    #[cfg(feature = "container")]
763    if crate::adapter::container::detect(source, limits) {
764        return DocumentFormat::Container;
765    }
766    // The scientific container (Phase 21.34) is a **standalone** format with strong
767    // magics: NetCDF classic (`CDF\x01`/`CDF\x02` at offset 0) and HDF5 (the 8-byte
768    // signature at offset 0 or 512 plus a validated superblock). It is inserted with
769    // the other strong-magic binaries (after the office ZIP family and the archive
770    // container, before Parquet) and **before** every weak, no-magic-byte heuristic
771    // (JSON/YAML/CSV/Markdown/XML/HTML), which a binary scientific file must never be
772    // guessed to be. It is deliberately conservative on HDF5: a random
773    // `\x89HDF`-prefixed blob with a bad superblock, and prose, stay `Opaque`.
774    // NetCDF's magic is the 4-byte `CDF\x01`/`CDF\x02`; a `CDF`-prefixed blob is
775    // admitted here and its derived model then declines typed if the header is
776    // malformed (the whole-source exact leaf is always unaffected).
777    #[cfg(feature = "hdf")]
778    if crate::adapter::hdf::detect(source, limits) {
779        return DocumentFormat::Hdf;
780    }
781    // The CFBF / OLE2 compound file (Phase 21.35) is a **standalone** physical-layer
782    // format with a strong 8-byte signature (`D0 CF 11 E0 A1 B1 1A E1`) plus a
783    // validated header. It is inserted with the other strong-magic binaries (after the
784    // office ZIP family, the archive container, and the scientific container, and
785    // **before** Parquet) and **before** every weak, no-magic-byte heuristic
786    // (JSON/YAML/CSV/Markdown/XML/HTML), which a binary compound file must never be
787    // guessed to be. It is deliberately conservative: a signature-only blob (its
788    // header fields are zero) and prose stay `Opaque`; a blob with a plausible header
789    // but a malformed body is admitted and its derived model then declines typed (the
790    // whole-source exact leaf is always unaffected).
791    #[cfg(feature = "cfbf")]
792    if crate::adapter::cfbf::detect(source, limits) {
793        // A legacy Word 97–2003 (`.doc`, MS-DOC) source is a CFBF compound file whose
794        // `WordDocument` stream opens with the Word FIB. The legacy-DOC detector is
795        // built **on top of** the CFBF detector and runs here, before the generic
796        // `Cfbf` claim, so a `.doc` becomes `Doc` while a generic compound file
797        // without a Word FIB stays `Cfbf`. A non-CFBF source is never reached.
798        #[cfg(feature = "doc")]
799        if crate::adapter::doc::detect(source, limits) {
800            return DocumentFormat::Doc;
801        }
802        // A legacy Excel 97–2003 (`.xls`, BIFF8) source is a CFBF compound file whose
803        // `Workbook`/`Book` stream opens with a BIFF8 `BOF`. Its detector is likewise
804        // built **on top of** the CFBF detector and runs here (after the Word detector,
805        // which cannot match an Excel compound file), before the generic `Cfbf` claim,
806        // so a `.xls` becomes `Xls` while a generic compound file stays `Cfbf`.
807        #[cfg(feature = "xls")]
808        if crate::adapter::xls::detect(source, limits) {
809            return DocumentFormat::Xls;
810        }
811        // A legacy PowerPoint 97–2003 (`.ppt`, MS-PPT) source is a CFBF compound file
812        // whose `PowerPoint Document` stream opens with a `Document` record (recVer
813        // 0xF, recType 1000). Its detector is likewise built **on top of** the CFBF
814        // detector and runs here (after the Word and Excel detectors, which cannot
815        // match a PowerPoint compound file), before the generic `Cfbf` claim, so a
816        // `.ppt` becomes `Ppt` while a generic compound file stays `Cfbf`.
817        #[cfg(feature = "ppt")]
818        if crate::adapter::ppt::detect(source, limits) {
819            return DocumentFormat::Ppt;
820        }
821        // A legacy Outlook message (`.msg`, MS-OXMSG) source is a CFBF compound file
822        // whose root storage has a `__properties_version1.0` stream. Its detector is
823        // likewise built **on top of** the CFBF detector and runs here (after the
824        // Word, Excel, and PowerPoint detectors and before the generic `Cfbf` claim),
825        // so a `.msg` becomes `Msg` while a generic compound file stays `Cfbf`. The
826        // boundary is honest: an OLE file that merely has a root-level stream with that
827        // name is admitted here, and its derived model then declines typed if the
828        // stream is not a well-formed message property list.
829        #[cfg(feature = "msg")]
830        if crate::adapter::msg::detect(source, limits) {
831            return DocumentFormat::Msg;
832        }
833        return DocumentFormat::Cfbf;
834    }
835    // Parquet is the **analytical** Wave-2 format (Phase 21.14), with no package
836    // layer. Its detection is a strong magic-byte contract — `PAR1` at both ends
837    // plus a consistent little-endian footer length — so it runs immediately after
838    // the package families and **before** the weak, no-magic-byte heuristics
839    // (JSON/YAML/CSV/Markdown/XML/HTML), which a binary columnar file must never be
840    // guessed to be. It is deliberately conservative: a file that merely begins or
841    // ends with `PAR1` but whose footer length overruns the leading magic stays
842    // `Opaque`.
843    #[cfg(feature = "parquet")]
844    if crate::adapter::parquet::detect(source, limits) {
845        return DocumentFormat::Parquet;
846    }
847    // Arrow IPC is the **analytical** Wave-2 format (Phase 21.16), with no package
848    // layer. Its detection is a strong magic-byte contract — the `ARROW1` magic at
849    // the start plus either a consistent trailing footer (file format) or a valid
850    // encapsulated `Schema` message (stream format) — so it runs immediately after
851    // Parquet and **before** the weak, no-magic-byte heuristics, which a binary
852    // columnar file must never be guessed to be. It is deliberately conservative:
853    // `ARROW1`-prefixed junk with neither a consistent footer nor a valid schema
854    // message stays `Opaque`.
855    #[cfg(feature = "arrow")]
856    if crate::adapter::arrow::detect(source, limits) {
857        return DocumentFormat::ArrowIpc;
858    }
859    // JSON is a Wave-2 structured-tree format with **no** package layer, so it is
860    // detected directly from the whole source (never through `detect_zip_family`).
861    // Conservative: the entire source must parse as exactly one JSON value within
862    // the JSON caps, else the input stays Opaque (Phase 21.5.1).
863    //
864    // GeoJSON is the **spatial** Wave-2 format (Phase 21.22), whose physical bytes
865    // are JSON, so it is a **bounded semantic sub-detection** run **before** the
866    // generic JSON detector below: the source must parse as exactly one JSON value
867    // whose root is an object with a `"type"` string equal to one of the nine RFC 7946
868    // type names **and** whose shape is consistent (a geometry has an array
869    // `coordinates`/`geometries`, a `Feature` has `geometry`/`properties`, a
870    // `FeatureCollection` has an array `features`). A more specific claim than a bare
871    // JSON value, so it is tried first; a plain JSON document, and a JSON document
872    // whose `"type"` is an unrelated string, decline here and are then claimed by the
873    // JSON detector (staying `Json`). Prose and malformed input stay `Opaque`.
874    #[cfg(feature = "geojson")]
875    if crate::adapter::geojson::detect(source, limits) {
876        return DocumentFormat::Geojson;
877    }
878    // A Jupyter notebook (`.ipynb`, nbformat) is the **document-shaped** Wave-2 format
879    // (Phase 21.24), whose physical bytes are JSON, so it is a **bounded semantic
880    // sub-detection** run **before** the generic JSON detector below: the source must
881    // parse as exactly one JSON value that is an nbformat-shaped object — a plain
882    // non-negative integer-literal `nbformat` (≥ 1), an array `cells`, every cell an
883    // object with a string `cell_type`, and every present recognized field
884    // nbformat-shaped (a string-or-array-of-strings `source`, a number-or-null
885    // `execution_count`, object `metadata`/`attachments`, an array `outputs` of objects
886    // with a string `output_type`). A more specific claim than a bare JSON value, so it
887    // is tried first; a plain JSON document, and a JSON document that merely has a
888    // `cells` key but is not nbformat-shaped, decline here and are then claimed by the
889    // JSON detector (staying `Json`). Prose and malformed input stay `Opaque`.
890    #[cfg(feature = "notebook")]
891    if crate::adapter::notebook::detect(source, limits) {
892        return DocumentFormat::Notebook;
893    }
894    // A package-metadata manifest (Phase 21.29) — an npm `package.json` /
895    // `package-lock.json`, a Cargo `Cargo.toml` / `Cargo.lock`, or a Python
896    // `pyproject.toml` — is the **manifest** Wave-2 format, whose physical bytes are
897    // JSON or TOML. It is a **bounded semantic sub-detection** run **before** the
898    // generic JSON and TOML detectors below: the source must carry a strong manifest
899    // shape (a JSON object with string `name`+`version` and a third package-specific
900    // key, a JSON `lockfileVersion`+`packages`/`dependencies`, a TOML `[package]` table
901    // with a string `name`, a TOML `[build-system]`/`[project]`/`[tool.poetry]`, or a
902    // TOML `[[package]]` lockfile shape). A more specific claim than a bare JSON/TOML
903    // value, so it is tried first; a generic JSON `{"name":…,"version":…}`, a generic
904    // TOML `name = …` / `version = …`, and plain prose decline here and are then
905    // claimed by the JSON detector (staying `Json`), the TOML detector (staying
906    // `Toml`), or the opaque floor. Detection is content-only and never consults a
907    // file name.
908    #[cfg(feature = "pkgmeta")]
909    if crate::adapter::pkgmeta::detect(source, limits) {
910        return DocumentFormat::Pkgmeta;
911    }
912    // An API-specification document (Phase 21.30) — a JSON Schema, an OpenAPI 3.x, a
913    // Swagger 2.0, or an AsyncAPI document — is the **API/spec** Wave-2 format, whose
914    // physical bytes are JSON. It is a **bounded semantic sub-detection** run **before**
915    // the generic JSON detector below: the root must be a JSON object carrying a
916    // **strong, root-level string marker** — a JSON-Schema `$schema` URI, an `openapi`
917    // `3.`-prefixed string, a `swagger` equal to `"2.0"`, or a non-empty `asyncapi`
918    // string. A more specific claim than a bare JSON value, so it is tried first; a
919    // plain JSON object that merely has a `properties`/`paths` key, a JSON-Schema-shaped
920    // object with **no** `$schema`, an unrelated `openapi`/`swagger` value, and prose
921    // decline here and are then claimed by the JSON detector (staying `Json`) or the
922    // opaque floor. Detection is content-only and never consults a file name.
923    #[cfg(feature = "apispec")]
924    if crate::adapter::apispec::detect(source, limits) {
925        return DocumentFormat::Apispec;
926    }
927    // A bibliography (Phase 21.31) — BibTeX, RIS, or CSL-JSON — is the
928    // **bibliography** Wave-2 format. BibTeX/RIS are line-anchored text and CSL-JSON's
929    // physical bytes are JSON, so this is a **bounded content-only test** run **before**
930    // the generic JSON detector below (a CSL-JSON array is otherwise claimed by the JSON
931    // detector, which is the weaker claim) and **before** the weak text heuristics
932    // (YAML/TOML/config/CSV/Markdown/RST/AsciiDoc/logstream/fixedwidth), which a BibTeX
933    // `@type{…}` file or an RIS `TY  - ` stream must never be guessed to be. It claims
934    // only on a strong signal: a line-anchored `@type{` entry sequence, an RIS `TY  - `
935    // entry sequence whose every entry is `ER  - `-terminated, or a JSON array (or
936    // single object) whose every element carries a string `id` **and** a string `type`.
937    // A plain JSON array, a JSON array of non-CSL objects, a generic JSON object, and
938    // prose decline here and are then claimed by the JSON detector (staying `Json`) or
939    // the opaque floor. Detection is content-only and never consults a file name.
940    #[cfg(feature = "bib")]
941    if crate::adapter::bib::detect(source, limits) {
942        return DocumentFormat::Bib;
943    }
944    // RTF (Rich Text Format, Phase 21.32) is a text group-brace grammar with a strong
945    // header magic (`{\rtf<digit>`), so it is tried here with the **strong-magic**
946    // detectors (right after the binary magics PDF/ZIP/Parquet/Arrow and before the
947    // JSON family). Its detection is magic + a bounded brace-balance scan, so no other
948    // format's bytes can steal it and it can steal no other format: an RTF source
949    // begins with `{`, which is not a valid JSON document (JSON would need a valid
950    // value), not a config `KEY=VALUE` line, and not a Markdown/RST structural mark;
951    // and the binary detectors above reject it. A plain text or leading-`{` blob stays
952    // `Opaque`.
953    #[cfg(feature = "rtf")]
954    if crate::adapter::rtf::detect(source, limits) {
955        return DocumentFormat::Rtf;
956    }
957    #[cfg(feature = "json")]
958    if crate::adapter::json::detect(source, limits) {
959        return DocumentFormat::Json;
960    }
961    // JSON5/JSONC is the **structured-extra** Wave-2 format (Phase 21.17.1), also
962    // with no package layer. It is tried **immediately after JSON** and before
963    // JSONL/YAML/TOML/CSV/Markdown/XML/HTML: JSON5 is a superset of JSON, so a
964    // strict JSON source is already claimed by the JSON detector above, and this
965    // detector requires at least one JSON5/JSONC-only construct (a comment, a
966    // trailing comma, an unquoted key, a single quote, a hex/leading-dot/
967    // `Infinity`/`NaN` number, a string continuation, or the extended whitespace
968    // set). A strict JSON document is therefore never reclassified, and a plain
969    // non-JSON blob stays Opaque.
970    #[cfg(feature = "json5")]
971    if crate::adapter::json5::detect(source, limits) {
972        return DocumentFormat::Json5;
973    }
974    // JSONL/NDJSON is the **line/event-stream** Wave-2 format (Phase 21.12), also
975    // with no package layer. It is tried **immediately after JSON** because it is the
976    // most specific signal for a newline-separated JSON stream: each non-blank line
977    // must parse as exactly one JSON value, which is stricter than — and must run
978    // before — the YAML stream reader (which would otherwise be free to reinterpret a
979    // line-delimited JSON stream as a stream of documents). Detection is
980    // conservative: at least two non-blank lines are required and every non-blank
981    // line must parse as exactly one JSON value under the caps, so a single JSON
982    // value, a malformed line, and a bag of values with a non-newline separator all
983    // stay `Opaque`.
984    #[cfg(feature = "jsonl")]
985    if crate::adapter::jsonl::detect(source, limits) {
986        return DocumentFormat::Jsonl;
987    }
988    // CBOR is the **binary** structured-tree Wave-2 format (Phase 21.18), also with
989    // no package layer. It is tried **immediately after the JSON family** (JSON,
990    // JSON5, JSONL) and **before** the remaining textual heuristics
991    // (EML/YAML/TOML/CSV/Markdown/XML/HTML): the strong magic-byte binaries
992    // (PDF/ZIP/Parquet/Arrow) run above and are never reconsidered. CBOR has **no
993    // magic bytes**, so its detector is deliberately conservative — the
994    // self-described-CBOR tag `55799` (`0xd9 0xd9 0xf7`), or a full-input,
995    // well-formed parse whose root is a container/tag and which reaches at least
996    // three nodes. A container head byte is always `>= 0x80`, so no pure-ASCII
997    // document (JSON/YAML/TOML/XML/HTML/prose) can be claimed; a lone scalar, an
998    // empty container, a truncated item, and a structurally trivial input all stay
999    // `Opaque` rather than being guessed. The small-integer/short-array encodings
1000    // overlap MessagePack's fixint/fixarray (recorded honestly; a Phase-21.19
1001    // MessagePack adapter must share this seam).
1002    #[cfg(feature = "cbor")]
1003    if crate::adapter::cbor::detect(source, limits) {
1004        return DocumentFormat::Cbor;
1005    }
1006    // MessagePack is the **binary** structured-tree Wave-2 sibling of CBOR (Phase
1007    // 21.19), also with no package layer. It is tried **immediately after CBOR** and
1008    // **before** the remaining textual heuristics (EML/YAML/TOML/CSV/Markdown/XML/
1009    // HTML): the strong magic-byte binaries (PDF/ZIP/Parquet/Arrow) and the JSON
1010    // family run above and are never reconsidered. **Placement is load-bearing: the
1011    // two binary detectors share the whole-number/short-container prefix, so putting
1012    // CBOR first guarantees its stronger self-described-tag signal (and every input
1013    // well-formed under both grammars) is never stolen from it.** MessagePack has
1014    // **no magic bytes**, so its detector is deliberately conservative — a
1015    // full-input, well-formed parse whose root is a container (array/map) reaching at
1016    // least three items and eight bytes, or the same with an unambiguous
1017    // MessagePack-only head byte (`0xdc..=0xdf`, which CBOR's grammar rejects). A
1018    // container head byte is always `>= 0x80`, so no pure-ASCII document can be
1019    // claimed; a lone scalar, an empty container, the ambiguous
1020    // `fixarray(3)`/`fixmap(2)` overlap fixtures, and a structurally trivial input
1021    // all stay `Opaque` rather than being guessed.
1022    #[cfg(feature = "msgpack")]
1023    if crate::adapter::msgpack::detect(source, limits) {
1024        return DocumentFormat::Msgpack;
1025    }
1026    // MHTML (MIME HTML, RFC 2557) is the **web-archive** Wave-2 format (Phase
1027    // 21.27), whose physical bytes are a MIME `multipart/related` message. It is a
1028    // **bounded semantic sub-detection** run **before** the generic EML detector
1029    // below (a `multipart/related` message is otherwise claimed by EML, which is a
1030    // weaker claim): the root must be `multipart/related` **and** carry an
1031    // MHTML-specific signal — a top-level `Snapshot-Content-Location`/`Content-Base`,
1032    // or a `From`+`Subject` envelope with a `text/html` part. A plain
1033    // `multipart/related` email without those markers, a plain HTML document (no MIME
1034    // envelope), and a plain MIME message (not `multipart/related`) all decline here
1035    // and are then claimed by their own detectors (`Eml`/`Html`/`Eml`).
1036    #[cfg(feature = "mhtml")]
1037    if crate::adapter::mhtml::detect(source, limits) {
1038        return DocumentFormat::Mhtml;
1039    }
1040    // EML/MIME is the **messaging** Wave-2 format (Phase 21.13), also with no package
1041    // layer. It is tried **immediately after JSONL** and **before YAML/TOML/CSV/
1042    // Markdown/XML/HTML**: a raw message begins with an RFC 5322 header block, which
1043    // the YAML stream reader would otherwise reinterpret as a mapping (e.g. `From: a`
1044    // / `Date: b`). Detection is conservative: a genuine header block terminated by a
1045    // blank line carrying `From`/`Date`/`Message-ID`, or an explicit `MIME-Version`,
1046    // is required, so prose without a header block and a colon-bearing note stay
1047    // `Opaque`. Only the header block is scanned (never the MIME tree).
1048    #[cfg(feature = "eml")]
1049    if crate::adapter::eml::detect(source, limits) {
1050        return DocumentFormat::Eml;
1051    }
1052    // YAML is the second Wave-2 structured-tree format (Phase 21.6.1), also with
1053    // **no** package layer. It is detected directly and conservatively: the whole
1054    // source must parse as a bounded YAML stream under the YAML caps, and every
1055    // document root must be a mapping or a sequence, else the input stays Opaque.
1056    #[cfg(feature = "yaml")]
1057    if crate::adapter::yaml::detect(source, limits) {
1058        return DocumentFormat::Yaml;
1059    }
1060    // TOML is the next Wave-2 structured-tree format (Phase 21.11). Precedence
1061    // (explicit): TOML is tried **after** the strong, fully-parsed tree formats
1062    // (JSON, YAML) but **before** the weak, no-magic-byte heuristics (CSV,
1063    // Markdown, XML, HTML). TOML's positive signal is a complete, error-free parse,
1064    // which is far stronger than the CSV/Markdown heuristics; crucially, a TOML
1065    // comment (`# …` at column 0) is otherwise misread as a Markdown ATX heading and
1066    // would steal the document. A source that is valid JSON/YAML still wins (it is
1067    // tried first); a source that is valid XML/HTML never parses as TOML (it begins
1068    // with `<`), so nothing is lost there. TOML has no magic bytes and a plain prose
1069    // paragraph is not TOML, so detection is conservative: the whole source must
1070    // parse as TOML 1.0 under the TOML caps with no errors **and** carry at least one
1071    // key/value assignment. TOML's duplicate-key/redefinition rules are enforced, so
1072    // a doc that violates them is not detected. Plain prose and non-TOML text stay
1073    // Opaque.
1074    #[cfg(feature = "toml")]
1075    if crate::adapter::toml::detect(source, limits) {
1076        return DocumentFormat::Toml;
1077    }
1078    // The config family (INI / `.env` / Java `.properties`) is the key/value line
1079    // Wave-2 format (Phase 21.20). Precedence (explicit): it is tried **after** the
1080    // strong, fully-parsed tree formats (JSON/JSON5/JSONL/YAML/TOML) — a TOML
1081    // document is very often a syntactically valid INI file, so a source that is
1082    // valid TOML must stay `Toml` (never reclassified) — and **before** CSV/Markdown/
1083    // XML/HTML. It precedes CSV deliberately: a config file whose values contain
1084    // commas (e.g. `A=x,y`) has a consistent field count and would otherwise be
1085    // stolen by the CSV detector, whereas the config `KEY=VALUE` shape is the more
1086    // specific signal. Config has **no magic bytes**, so detection is deliberately
1087    // conservative and requires a **dialect-distinguishing signal**: an INI
1088    // `[section]` header; a properties `:`/whitespace separator, `!` comment,
1089    // `\uXXXX` escape, continuation, or non-identifier key (plus a strong `=`/`:`
1090    // separator somewhere); or an `env` `export ` prefix. The pure `KEY=VALUE`
1091    // overlap between `env` and `properties`, plain prose, a two-column `a b`
1092    // blob, and a `#!` script all stay `Opaque` rather than being guessed; a plain
1093    // `.txt`/Markdown/code source is never claimed.
1094    #[cfg(feature = "config")]
1095    if crate::adapter::config::detect(source, limits) {
1096        return DocumentFormat::Config;
1097    }
1098    // CSV/TSV is the first **tabular** Wave-2 format (Phase 21.7.1). It has no
1099    // magic bytes, so it is detected last and conservatively: only after the
1100    // PDF/ZIP/JSON/YAML families are declined does a source qualify, and then only
1101    // if it parses under a specific delimiter (`,`, tab, or `|`) as a table with a
1102    // consistent field count across a sampled majority of records and at least two
1103    // columns. Phase 21.25 adds the pipe delimiter to this dialect set; the pipe
1104    // dialect is tried after comma/tab and is declined when the source carries a
1105    // GFM/Markdown delimiter row (so a Markdown table is never stolen). When in
1106    // doubt the input stays Opaque.
1107    #[cfg(feature = "csv")]
1108    if crate::adapter::csv::detect(source, limits) {
1109        return DocumentFormat::Csv;
1110    }
1111    // MDX (Phase 21.26.3) is Markdown + JSX/ESM, so it is tried **immediately before
1112    // Markdown** (and therefore before HTML/XML and the other prose/tabular
1113    // heuristics): an MDX document's bytes are Markdown-plus-JSX, so generic Markdown
1114    // must never claim it first. Detection is conservative and **MDX-specific** — the
1115    // source must parse under the Markdown model **and** carry a top-level ESM
1116    // `import`/`export` statement, a JSX component (a capitalized/namespaced element
1117    // name) or fragment, a JSX-specific attribute (a brace value or spread), or a
1118    // whole-line MDX block expression `{…}` — and it declines anything already
1119    // claimed by JSON/YAML/CSV/HTML/XML/RST/AsciiDoc, so a plain Markdown document
1120    // stays `Markdown`, a plain HTML document stays `Html`, and plain prose stays
1121    // `Opaque`.
1122    #[cfg(feature = "mdx")]
1123    if crate::adapter::mdx::detect(source, limits) {
1124        return DocumentFormat::Mdx;
1125    }
1126    // Markdown is the first **prose** Wave-2 format (Phase 21.8.1). It has no
1127    // magic bytes either, and a plain prose paragraph is itself valid Markdown, so
1128    // detection is deliberately conservative: only after the PDF/ZIP/JSON/YAML/CSV
1129    // families are declined, and only when the source carries a clear structural
1130    // mark (an ATX heading, a fenced code block, front matter, a table, a reference
1131    // definition, or a footnote definition), is it admitted. Plain prose stays
1132    // Opaque rather than being guessed to be Markdown.
1133    #[cfg(feature = "markdown")]
1134    if crate::adapter::markdown::detect(source, limits) {
1135        return DocumentFormat::Markdown;
1136    }
1137    // reStructuredText (Phase 21.26.1) is the **next prose** Wave-2 format, also with
1138    // no package layer. It is tried **immediately after Markdown** and **before
1139    // fixed-width**: Markdown must be tried first so a Markdown document is never
1140    // stolen (reST detection requires a reST-**specific** signal — an explicit markup
1141    // start, a grid/simple table, a field list, or an adornment whose char is not a
1142    // Markdown construct), and a reST document must be claimed before the maximally
1143    // ambiguous fixed-width heuristic (which could otherwise read an aligned reST body
1144    // as a column-position table). Plain prose stays Opaque.
1145    #[cfg(feature = "rst")]
1146    if crate::adapter::rst::detect(source, limits) {
1147        return DocumentFormat::Rst;
1148    }
1149    // AsciiDoc (Phase 21.26.2) is the **third prose** Wave-2 format, also with no
1150    // package layer. It is tried **immediately after reStructuredText** and **before
1151    // fixed-width**: Markdown and reST must be tried first so neither is stolen (a
1152    // valid Markdown or reST document always keeps its own format), and AsciiDoc must
1153    // be claimed before the maximally ambiguous fixed-width heuristic (which could
1154    // otherwise read an aligned AsciiDoc body as a column-position table). The
1155    // dispatcher already tries PDF/ZIP/JSON/YAML/config/CSV ahead of it, so a
1156    // `:name: value`-shaped config source and a `#`/fence-bearing Markdown source are
1157    // claimed earlier. Detection is conservative and AsciiDoc-specific (a document
1158    // title followed by a block, a `==`+ section, a `|===` table, a complete
1159    // delimited block, or a block attribute line followed by a block); a lone `= text`
1160    // line and plain prose stay Opaque.
1161    #[cfg(feature = "asciidoc")]
1162    if crate::adapter::asciidoc::detect(source, limits) {
1163        return DocumentFormat::Asciidoc;
1164    }
1165    // A syslog / log stream is the **line/event-stream** Wave-2 format (Phase 21.28),
1166    // also with no package layer. Precedence (explicit): it is tried **after** every
1167    // structured and prose format above — the JSON family (JSON/JSON5/JSONL/CBOR/
1168    // Msgpack), MHTML/EML, YAML/TOML, the config family, CSV, and the prose family
1169    // (MDX/Markdown/RST/AsciiDoc) — so a JSON/YAML/CSV/prose document is never stolen;
1170    // and **before** the maximally ambiguous fixed-width heuristic, which could
1171    // otherwise read a column-aligned log stream as a column-position table. It is
1172    // tried before the XML/HTML family too, but a log line never begins with a valid
1173    // XML/HTML root (a syslog `<PRI>` is not well-formed XML). Detection is byte-based
1174    // and conservative: at least two non-blank lines, and **every** non-blank line must
1175    // match a dialect — an RFC 5424 syslog header, an RFC 3164 (BSD) syslog line, or a
1176    // generic application log line with a leading timestamp and/or level token. Plain
1177    // prose and code stay `Opaque` (a prose line that does not begin with a timestamp/
1178    // level declines the whole file; a file whose every line begins with a level word
1179    // IS claimed — recorded honestly in the adapter docs).
1180    #[cfg(feature = "logstream")]
1181    if crate::adapter::logstream::detect(source, limits) {
1182        return DocumentFormat::Logstream;
1183    }
1184    // Fixed-width (column-position) text is the second **tabular** Wave-2 format
1185    // (Phase 21.25), but its columns are defined by character positions, not a
1186    // delimiter, so it is a distinct format (and adapter), never a CSV dialect. It is
1187    // tried **last among the tabular/prose heuristics** — after CSV/TSV/PSV, after
1188    // Markdown, and after reStructuredText — so a delimited table, a Markdown table, or
1189    // a reST document always keeps its own format (the fixed-width detector also
1190    // declines a delimited or Markdown table explicitly, defence in depth).
1191    // Fixed-width is **genuinely ambiguous** (nearly any aligned text can look
1192    // tabular), so detection is maximally conservative: at least three sampled records
1193    // of identical byte width, an inferred column layout whose interior whitespace gaps
1194    // are at least two columns wide, and at least two non-empty columns; a
1195    // variable-length or single-space-separated blob stays Opaque.
1196    #[cfg(feature = "fixedwidth")]
1197    if crate::adapter::fixedwidth::detect(source, limits) {
1198        return DocumentFormat::FixedWidth;
1199    }
1200    // RSS/Atom is the **syndication** Wave-2 format (Phase 21.21). A feed's physical
1201    // bytes are XML, so this is a **bounded semantic sub-detection** run **before**
1202    // the generic standalone-XML detector below: an RSS root `<rss>` with a
1203    // `<channel>` child, or an Atom root `<feed>` in the Atom namespace
1204    // (`http://www.w3.org/2005/Atom`) with at least one `<entry>` child. A feed is a
1205    // more specific claim than a bare XML tree, so it is tried first; a plain XML
1206    // document (a root that is neither `<rss>` nor an Atom `<feed>`, or an
1207    // `<rss>`-shaped-but-invalid / non-Atom / record-less `<feed>`) declines here and
1208    // falls through to the XML detector (staying `Xml`) or to `Opaque`. An HTML
1209    // document (`<!doctype html>` / an `<html>` root) simply declines here and is
1210    // then claimed by the HTML document-level marker immediately below.
1211    #[cfg(feature = "feed")]
1212    if crate::adapter::feed::detect(source, limits) {
1213        return DocumentFormat::Feed;
1214    }
1215    // KML/GPX is the **geospatial** Wave-2 format (Phase 21.23). Its physical bytes
1216    // are XML, so this is a **bounded semantic sub-detection** run **before** the
1217    // generic standalone-XML detector below: a `<kml>` root in the KML namespace
1218    // (`http://www.opengis.net/kml/2.2`) with a `Document`/`Folder`/`Placemark` child,
1219    // or a `<gpx>` root in the GPX namespace (`http://www.topografix.com/GPX/1/1`)
1220    // with a `metadata`/`wpt`/`rte`/`trk` child. A geospatial document is a more
1221    // specific claim than a bare XML tree, so it is tried first; a plain XML document
1222    // (a root that is neither `<kml>` nor `<gpx>`, or a shaped-but-invalid / non-GIS
1223    // namespace / child-less root) declines here and falls through to the XML detector
1224    // (staying `Xml`) or to `Opaque`. An older KML (2.0/2.1) or GPX 1.0 namespace is
1225    // not the claimed URI, so it also stays `Xml`. An HTML document simply declines
1226    // here and is then claimed by the HTML document-level marker immediately below.
1227    #[cfg(feature = "gis")]
1228    if crate::adapter::gis::detect(source, limits) {
1229        return DocumentFormat::Gis;
1230    }
1231    // HTML document-level marker (Phase 21.10 / 21.15 fix): a `<!doctype html>` or
1232    // an `<html>` root element is a more specific signal than the generic XML
1233    // fallback, so a **well-formed XHTML page** is classified `Html`, not `Xml`
1234    // (XML's well-formedness parser would otherwise claim it). The general XML
1235    // branch below is still tried before the general HTML branch, so a bare XML
1236    // tree stays XML and HTML only claims the sources XML declines.
1237    #[cfg(feature = "html")]
1238    if crate::adapter::html::has_document_marker(source)
1239        && crate::adapter::html::detect(source, limits)
1240    {
1241        return DocumentFormat::Html;
1242    }
1243    // XML is the structured-tree Wave-2 format for a bare XML source (Phase 21.9).
1244    // It has no package layer and no magic bytes beyond `<`, so it is detected last
1245    // and conservatively: only after the PDF/ZIP/JSON/YAML/CSV/Markdown families are
1246    // declined, only when the source begins with `<` (an XML prolog or a root
1247    // element start), and only when the whole source parses as well-formed XML with
1248    // exactly one root element under the XML caps. `<-prefixed` junk and prose stay
1249    // Opaque rather than being guessed to be XML.
1250    #[cfg(feature = "xml")]
1251    if crate::adapter::xml::detect(source, limits) {
1252        return DocumentFormat::Xml;
1253    }
1254    // HTML is the error-recovering markup Wave-2 format (Phase 21.10). Precedence
1255    // note (explicit): a *document-level* HTML marker is handled above (before XML);
1256    // for every other `<`-bearing source XML is tried before HTML, so any document
1257    // XML accepts (a bare XML tree) is genuinely XML. HTML then claims the
1258    // `<`-bearing sources XML declines — real HTML5 is almost never well-formed XML
1259    // (void elements, optional end tags, unquoted attributes, undeclared named
1260    // entities). Detection is conservative: the source must carry clear HTML
1261    // structure (a `<!doctype html>`, an `<html`/`<head`/`<body` tag, or a
1262    // preponderance of known HTML tags). Plain prose and non-HTML `<`-junk stay
1263    // Opaque rather than being guessed to be HTML.
1264    #[cfg(feature = "html")]
1265    if crate::adapter::html::detect(source, limits) {
1266        return DocumentFormat::Html;
1267    }
1268    DocumentFormat::Opaque
1269}
1270
1271/// Whether `source` is a structurally valid ZIP archive.
1272///
1273/// Used by the universal ingest dispatcher so a generic ZIP (which detection
1274/// reports as `Opaque`) is still inverted through the byte-authoritative package
1275/// layer rather than the opaque floor.
1276#[cfg(feature = "package")]
1277pub fn is_zip(source: &[u8], limits: Limits) -> bool {
1278    crate::adapter::package::scan(source, limits).is_ok()
1279}
1280
1281#[cfg(feature = "package")]
1282fn detect_zip_family(source: &[u8], limits: Limits) -> Option<DocumentFormat> {
1283    let physical = crate::adapter::package::scan(source, limits).ok()?;
1284
1285    // EPUB (OCF): the mandatory `mimetype` member, or an OCF container that
1286    // resolves to an OPF rootfile.
1287    let mimetype = member_decoded(&physical, source, EPUB_MIMETYPE_MEMBER, limits);
1288    let mimetype_ok = mimetype.as_deref() == Some(EPUB_MIMETYPE);
1289    let container = member_decoded(&physical, source, CONTAINER_MEMBER, limits);
1290    let container_ok = container
1291        .as_deref()
1292        .is_some_and(|c| contains(c, CONTAINER_NS) && contains(c, OPF_MEDIA_TYPE));
1293    let is_epub = mimetype_ok || container_ok;
1294
1295    // DOCX: an OPC package whose content types declare a WordprocessingML main
1296    // part, or whose package relationships declare an `officeDocument` part that
1297    // targets `word/document.xml`. The positive WordprocessingML signal (rather
1298    // than the mere absence of a SpreadsheetML one) keeps DOCX and XLSX mutually
1299    // exclusive without misclassifying a Word document that *embeds* an Excel
1300    // workbook (whose package declares SpreadsheetML content types for the
1301    // embedded part, but no SpreadsheetML workbook main part). Without `xlsx`
1302    // and `pptx` the legacy relationship-only rule stands.
1303    let content_types = member_decoded(&physical, source, CONTENT_TYPES_MEMBER, limits);
1304    let rels = member_decoded(&physical, source, PACKAGE_RELS_MEMBER, limits);
1305    #[cfg(any(feature = "xlsx", feature = "pptx"))]
1306    let is_docx = content_types
1307        .as_deref()
1308        .is_some_and(|ct| contains(ct, DOCX_MAIN_FRAGMENT))
1309        || rels.as_deref().is_some_and(|r| {
1310            contains(r, OFFICE_DOCUMENT_FRAGMENT) && contains(r, DOCX_MAIN_TARGET)
1311        });
1312    #[cfg(not(any(feature = "xlsx", feature = "pptx")))]
1313    let is_docx = content_types.is_some()
1314        && rels
1315            .as_deref()
1316            .is_some_and(|r| contains(r, OFFICE_DOCUMENT_FRAGMENT));
1317
1318    // XLSX: an OPC package whose content types declare a SpreadsheetML workbook
1319    // (or whose `officeDocument` relationship targets a workbook part).
1320    #[cfg(feature = "xlsx")]
1321    let is_xlsx = content_types.as_deref().is_some_and(|ct| {
1322        contains(ct, XLSX_MAIN_FRAGMENT)
1323            || (contains(ct, XLSX_NS_FRAGMENT)
1324                && rels
1325                    .as_deref()
1326                    .is_some_and(|r| contains(r, XLSX_WORKBOOK_TARGET)))
1327    });
1328    #[cfg(not(feature = "xlsx"))]
1329    let is_xlsx = false;
1330
1331    // PPTX: an OPC package whose content types declare a PresentationML main part
1332    // (or whose content types name PresentationML and whose `officeDocument`
1333    // relationship targets `ppt/presentation.xml`). The positive PresentationML
1334    // signal keeps it mutually exclusive with DOCX and XLSX: a Word/Excel document
1335    // that *embeds* a PowerPoint part declares only the PresentationML embed type
1336    // (`…presentationml.presentation`, not the `.main+xml` main part) and its
1337    // `officeDocument` relationship targets `word/document.xml`/`xl/workbook.xml`,
1338    // so it is never misclassified as PPTX.
1339    #[cfg(feature = "pptx")]
1340    let is_pptx = content_types.as_deref().is_some_and(|ct| {
1341        contains(ct, PPTX_MAIN_FRAGMENT)
1342            || (contains(ct, PPTX_NS_FRAGMENT)
1343                && rels
1344                    .as_deref()
1345                    .is_some_and(|r| contains(r, PPTX_MAIN_TARGET)))
1346    });
1347    #[cfg(not(feature = "pptx"))]
1348    let is_pptx = false;
1349
1350    // The mandatory ODF `mimetype` member is **authoritative** when it names an
1351    // OpenDocument media type (ODF 1.2 §3.2: the package's own media type). An ODP
1352    // that *embeds a spreadsheet* lists both media types in `META-INF/manifest.xml`
1353    // (one per file-entry), so scanning the whole manifest would make it look like an
1354    // ODP *and* an ODS and force the ambiguous case below to Opaque. Only when the
1355    // `mimetype` member is absent or is not an ODF media type do we fall back to the
1356    // manifest scan (for a non-conformant package).
1357    #[cfg(any(feature = "odt", feature = "ods", feature = "odp"))]
1358    let odf_mimetype = mimetype
1359        .as_deref()
1360        .filter(|m| contains(m, ODF_MEDIA_PREFIX));
1361
1362    // ODT: an ODF package whose mandatory `mimetype` (or, absent that,
1363    // `META-INF/manifest.xml`) declares an OpenDocument text media type.
1364    #[cfg(feature = "odt")]
1365    let is_odt = match odf_mimetype {
1366        Some(m) => contains(m, ODT_TEXT_FRAGMENT),
1367        None => member_decoded(&physical, source, ODT_MANIFEST_MEMBER, limits)
1368            .as_deref()
1369            .is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT)),
1370    };
1371    #[cfg(not(feature = "odt"))]
1372    let is_odt = false;
1373
1374    // ODS: an ODF package whose mandatory `mimetype` (or, absent that, the manifest)
1375    // declares an OpenDocument *spreadsheet* media type. The positive spreadsheet
1376    // fragment keeps it mutually exclusive with ODT (a text document declares the
1377    // text media type, never the spreadsheet one).
1378    #[cfg(feature = "ods")]
1379    let is_ods = match odf_mimetype {
1380        Some(m) => contains(m, ODS_SPREADSHEET_FRAGMENT),
1381        None => member_decoded(&physical, source, ODS_MANIFEST_MEMBER, limits)
1382            .as_deref()
1383            .is_some_and(|m| contains(m, ODS_SPREADSHEET_FRAGMENT)),
1384    };
1385    #[cfg(not(feature = "ods"))]
1386    let is_ods = false;
1387
1388    // ODP: an ODF package whose mandatory `mimetype` (or, absent that, the manifest)
1389    // declares an OpenDocument *presentation* media type. The positive presentation
1390    // fragment keeps it mutually exclusive with ODT/ODS.
1391    #[cfg(feature = "odp")]
1392    let is_odp = match odf_mimetype {
1393        Some(m) => contains(m, ODP_PRESENTATION_FRAGMENT),
1394        None => member_decoded(&physical, source, ODP_MANIFEST_MEMBER, limits)
1395            .as_deref()
1396            .is_some_and(|m| contains(m, ODP_PRESENTATION_FRAGMENT)),
1397    };
1398    #[cfg(not(feature = "odp"))]
1399    let is_odp = false;
1400
1401    // A ZIP matching more than one native signature is ambiguous: fail safe.
1402    let matches = [is_docx, is_epub, is_odt, is_ods, is_odp, is_xlsx, is_pptx]
1403        .iter()
1404        .filter(|b| **b)
1405        .count();
1406    match matches {
1407        1 if is_docx => Some(DocumentFormat::Docx),
1408        1 if is_epub => Some(DocumentFormat::Epub),
1409        1 if is_odt => Some(DocumentFormat::Odt),
1410        1 if is_ods => Some(DocumentFormat::Ods),
1411        1 if is_odp => Some(DocumentFormat::Odp),
1412        1 if is_xlsx => Some(DocumentFormat::Xlsx),
1413        1 if is_pptx => Some(DocumentFormat::Pptx),
1414        _ => None,
1415    }
1416}
1417
1418#[cfg(feature = "package")]
1419const EPUB_MIMETYPE_MEMBER: &[u8] = b"mimetype";
1420
1421/// Decode one member's bytes by exact name, bounded and decline-safe: encrypted,
1422/// oversized, unsupported-method, or out-of-range members yield `None`. The name
1423/// match is ASCII case-insensitive, mirroring the OPC layer
1424/// ([`crate::field::opc`]), which resolves the well-known control parts
1425/// (`[Content_Types].xml`, `_rels/.rels`) case-insensitively; a package that
1426/// lowercases them is still identified (Phase 21.15 fix).
1427#[cfg(feature = "package")]
1428fn member_decoded(
1429    physical: &crate::adapter::package::ZipPhysical,
1430    source: &[u8],
1431    name: &[u8],
1432    limits: Limits,
1433) -> Option<Vec<u8>> {
1434    const FLAG_ENCRYPTED: u16 = 0x0001;
1435    let member = physical
1436        .members
1437        .iter()
1438        .find(|m| m.name.eq_ignore_ascii_case(name))?;
1439    if member.flags & FLAG_ENCRYPTED != 0 || member.uncompressed_size > limits.max_xml_part_bytes {
1440        return None;
1441    }
1442    let off = usize::try_from(member.data.0).ok()?;
1443    let len = usize::try_from(member.data.1).ok()?;
1444    let raw = source.get(off..off.checked_add(len)?)?;
1445    match member.method {
1446        0 => Some(raw.to_vec()),
1447        8 => crate::field::derive::inflate_raw_deflate(raw, member.uncompressed_size, limits).ok(),
1448        _ => None,
1449    }
1450}
1451
1452/// Byte-substring search (no allocation, case-sensitive).
1453#[cfg(feature = "package")]
1454fn contains(haystack: &[u8], needle: &[u8]) -> bool {
1455    !needle.is_empty()
1456        && needle.len() <= haystack.len()
1457        && haystack.windows(needle.len()).any(|w| w == needle)
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use super::*;
1463
1464    #[test]
1465    fn names_and_provenance_roundtrip() {
1466        for f in [
1467            DocumentFormat::Pdf,
1468            DocumentFormat::Docx,
1469            DocumentFormat::Epub,
1470            DocumentFormat::Odt,
1471            DocumentFormat::Ods,
1472            DocumentFormat::Odp,
1473            DocumentFormat::Xlsx,
1474            DocumentFormat::Pptx,
1475            DocumentFormat::Json,
1476            DocumentFormat::Json5,
1477            DocumentFormat::Yaml,
1478            DocumentFormat::Csv,
1479            DocumentFormat::Markdown,
1480            DocumentFormat::Xml,
1481            DocumentFormat::Html,
1482            DocumentFormat::Toml,
1483            DocumentFormat::Jsonl,
1484            DocumentFormat::Eml,
1485            DocumentFormat::Parquet,
1486            DocumentFormat::ArrowIpc,
1487            DocumentFormat::Cbor,
1488            DocumentFormat::Msgpack,
1489            DocumentFormat::Config,
1490            DocumentFormat::Feed,
1491            DocumentFormat::Geojson,
1492            DocumentFormat::Gis,
1493            DocumentFormat::Notebook,
1494            DocumentFormat::FixedWidth,
1495            DocumentFormat::Rst,
1496            DocumentFormat::Asciidoc,
1497            DocumentFormat::Mdx,
1498            DocumentFormat::Mhtml,
1499            DocumentFormat::Logstream,
1500            DocumentFormat::Pkgmeta,
1501            DocumentFormat::Apispec,
1502            DocumentFormat::Bib,
1503            DocumentFormat::Rtf,
1504            DocumentFormat::Opaque,
1505        ] {
1506            let token = format!("{}field:package;members=1", f.provenance_prefix());
1507            assert_eq!(DocumentFormat::from_provenance(&token), Some(f));
1508        }
1509        assert_eq!(DocumentFormat::from_provenance("field:ingest-b"), None);
1510        assert_eq!(DocumentFormat::from_provenance("format=exotic;x"), None);
1511    }
1512
1513    #[test]
1514    fn plain_bytes_are_opaque() {
1515        assert_eq!(
1516            detect_document_format(b"not a document", Limits::DEFAULT),
1517            DocumentFormat::Opaque
1518        );
1519    }
1520
1521    #[test]
1522    fn a_corpus_pdf_is_detected() {
1523        let (_, pdf) = crate::adapter::pdf::sample_pdfs()
1524            .into_iter()
1525            .next()
1526            .expect("the PDF corpus is non-empty");
1527        assert_eq!(
1528            detect_document_format(&pdf, Limits::DEFAULT),
1529            DocumentFormat::Pdf
1530        );
1531    }
1532}