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