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