From dd3ba06422b747fa3f78ae99012f6f11a6bb72db Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 27 Oct 2026 00:00:19 +0000 Subject: [PATCH 1/4] feat: pluggable FormatParser registry - registry::FormatParser (name, formats, claims, parse) and MarkkitBuilder::parser; consulted before built-ins - custom parser output goes through transforms and the renderer; panics map to new Error::Parser - ParsedDocument::new is public --- CHANGELOG.md | 3 + crates/markkit/src/config.rs | 10 ++ crates/markkit/src/error.rs | 4 + crates/markkit/src/extract.rs | 21 +++- crates/markkit/src/lib.rs | 1 + crates/markkit/src/registry.rs | 86 ++++++++++++++ crates/markkit/tests/registry.rs | 164 ++++++++++++++++++++++++++ knowledge/formats/format-support.md | 19 +++ knowledge/foundations/architecture.md | 2 +- knowledge/log.md | 1 + knowledge/security/threat-model.md | 9 ++ 11 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 crates/markkit/src/registry.rs create mode 100644 crates/markkit/tests/registry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e9801..c51a0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Declarative HTML element rules: `MarkkitBuilder::html_rule(".note", ElementAction::BlockQuote { .. })` maps elements by CSS selector to skip / unwrap / heading / paragraph / block quote (with title) / code / emphasis / text, before the built-in mapping. CLI: `markkit extract --rules rules.json`. +- Pluggable parsers: `MarkkitBuilder::parser(..)` registers a `registry::FormatParser` that handles + detected formats (e.g. PDF or CSV) or claims inputs by content, before the built-ins. Its IR goes + through transforms and the shared renderer; panics become the new `Error::Parser`. - JSON Schema for the IR at `schema/ir.schema.json`; `json-schema` feature adds `markkit::ir::json_schema()`. - Per-block provenance: `MarkkitBuilder::track_sources(true)` records a `Source` on each IR block (HTML element XPath, DOCX body block index, spreadsheet sheet and A1 range) for citations. diff --git a/crates/markkit/src/config.rs b/crates/markkit/src/config.rs index 2881408..c1e34f5 100644 --- a/crates/markkit/src/config.rs +++ b/crates/markkit/src/config.rs @@ -48,6 +48,8 @@ pub struct MarkkitBuilder { /// Declarative HTML element rules, tried in order before the built-in /// mapping (see [`crate::rules`]). pub html_rules: Vec, + /// Custom format parsers, consulted before the built-ins (see [`crate::registry`]). + pub parsers: crate::registry::ParserRegistry, } impl Default for MarkkitBuilder { @@ -75,6 +77,7 @@ impl Default for MarkkitBuilder { transforms: Default::default(), main_content: false, html_rules: Vec::new(), + parsers: Default::default(), } } } @@ -152,6 +155,13 @@ impl MarkkitBuilder { self } + /// Register a custom format parser; it runs instead of the built-in one + /// for the formats it handles or the inputs it claims. + pub fn parser(mut self, p: impl crate::registry::FormatParser + 'static) -> Self { + self.parsers.push(p); + self + } + pub fn include_selector(mut self, selector: impl Into) -> Self { self.include_selectors.push(selector.into()); self diff --git a/crates/markkit/src/error.rs b/crates/markkit/src/error.rs index 9e3f80b..905ab3b 100644 --- a/crates/markkit/src/error.rs +++ b/crates/markkit/src/error.rs @@ -31,6 +31,10 @@ pub enum Error { #[error("Resource limit exceeded: {0}")] ResourceLimit(String), + /// A custom [`crate::registry::FormatParser`] failed or panicked. + #[error("Parser error: {0}")] + Parser(String), + /// A [`crate::transform::Transform`] failed. #[error("Transform error: {0}")] Transform(String), diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 9cab775..85b76d8 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -94,7 +94,9 @@ pub struct ParsedDocument { } impl ParsedDocument { - fn new(document: Document, format: Format) -> Self { + /// IR plus format with empty metadata (for custom + /// [`crate::registry::FormatParser`]s). + pub fn new(document: Document, format: Format) -> Self { Self { document, title: None, @@ -313,6 +315,10 @@ impl Markkit { self.config.limits.check_input(bytes.len())?; let format = Format::detect(bytes, hint); + if let Some(p) = self.config.parsers.select(bytes, hint, format) { + return self.parse_custom(p, bytes, budget); + } + match format { Format::Html => self.parse_html_doc(bytes, budget), Format::Pdf => self.parse_pdf(bytes, budget), @@ -336,6 +342,19 @@ impl Markkit { } } + fn parse_custom( + &self, + p: &dyn crate::registry::FormatParser, + bytes: &[u8], + budget: &Budget, + ) -> crate::Result { + let mut parsed = guarded(p.name(), Error::Parser, || p.parse(bytes, &self.config))?; + // Custom parsers are not interruptible: check the deadline afterwards. + budget.check_time()?; + self.config.transforms.apply(&mut parsed.document)?; + Ok(parsed) + } + #[cfg(feature = "html")] fn parse_html_doc(&self, bytes: &[u8], budget: &Budget) -> crate::Result { let text = String::from_utf8_lossy(bytes); diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index ab35a0f..42632d5 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -67,6 +67,7 @@ pub mod limits; mod markdown; #[cfg(feature = "pdf")] mod pdf; +pub mod registry; pub mod render; pub mod rules; #[cfg(feature = "spreadsheet")] diff --git a/crates/markkit/src/registry.rs b/crates/markkit/src/registry.rs new file mode 100644 index 0000000..c894cef --- /dev/null +++ b/crates/markkit/src/registry.rs @@ -0,0 +1,86 @@ +//! Pluggable format parsers. +//! +//! Decisions: +//! - Embedders register [`FormatParser`]s on the builder +//! ([`crate::MarkkitBuilder::parser`]). For each document, registered +//! parsers are consulted before the built-ins, in registration order: +//! first one whose [`FormatParser::claims`] returns true, else the first +//! one listing the detected [`Format`] in [`FormatParser::formats`]. +//! This lets an embedder add a format (PDF, EPUB, ...) or replace a +//! built-in without forking. +//! - A custom parser gets the same guarantees as built-ins: input is +//! size-checked first, a panic becomes [`crate::Error::Parser`], transforms +//! run on its output, and the shared renderer produces the Markdown. Its own +//! resource limits are its responsibility (it sees the [`MarkkitBuilder`]). +//! - Parsers are `Arc`-shared so the builder stays `Clone`. + +use std::fmt; +use std::sync::Arc; + +use crate::config::MarkkitBuilder; +use crate::extract::ParsedDocument; +use crate::format::{DocumentHint, Format}; + +/// A parser from bytes to the IR. +pub trait FormatParser: Send + Sync { + /// Short name for debugging and error messages. + fn name(&self) -> &str; + + /// Detected formats this parser handles (see [`Format::detect`]). + fn formats(&self) -> &[Format] { + &[] + } + + /// Claim input before format detection (for formats [`Format`] has no + /// variant for). Inputs are untrusted; keep this cheap. + fn claims(&self, _bytes: &[u8], _hint: DocumentHint<'_>) -> bool { + false + } + + /// Parse `bytes` (already within `config.max_input_size`) into the IR. + fn parse(&self, bytes: &[u8], config: &MarkkitBuilder) -> crate::Result; +} + +/// Ordered list of custom parsers held by [`MarkkitBuilder`]. +#[derive(Clone, Default)] +pub struct ParserRegistry(Vec>); + +impl ParserRegistry { + /// Register a parser (lower priority than those registered earlier). + pub fn push(&mut self, p: impl FormatParser + 'static) { + self.0.push(Arc::new(p)); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + /// Names of the parsers, in priority order. + pub fn names(&self) -> impl Iterator { + self.0.iter().map(|p| p.name()) + } + + /// Parser that claims the input, else the first one for `detected`. + pub(crate) fn select( + &self, + bytes: &[u8], + hint: DocumentHint<'_>, + detected: Format, + ) -> Option<&dyn FormatParser> { + self.0 + .iter() + .find(|p| p.claims(bytes, hint)) + .or_else(|| self.0.iter().find(|p| p.formats().contains(&detected))) + .map(|p| p.as_ref()) + } +} + +impl fmt::Debug for ParserRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.names()).finish() + } +} diff --git a/crates/markkit/tests/registry.rs b/crates/markkit/tests/registry.rs new file mode 100644 index 0000000..0a49381 --- /dev/null +++ b/crates/markkit/tests/registry.rs @@ -0,0 +1,164 @@ +//! Custom FormatParser registry. + +use markkit::ir::{Document, Node, TableCell}; +use markkit::registry::FormatParser; +use markkit::{DocumentHint, Error, Format, Markkit, MarkkitBuilder, ParsedDocument}; + +/// Naive CSV: comma-split lines, first line is the header. +struct Csv; + +impl FormatParser for Csv { + fn name(&self) -> &str { + "csv" + } + fn formats(&self) -> &[Format] { + &[Format::Csv] + } + fn parse(&self, bytes: &[u8], _: &MarkkitBuilder) -> markkit::Result { + let text = String::from_utf8_lossy(bytes); + let mut rows = text.lines().map(|l| { + l.split(',') + .map(|c| TableCell::new(vec![Node::Text(c.trim().into())])) + .collect::>() + }); + let headers = rows.next().unwrap_or_default(); + let table = Node::Table { + alignments: vec![], + headers, + rows: rows.collect(), + source: None, + }; + Ok(ParsedDocument::new(Document::new(vec![table]), Format::Csv)) + } +} + +/// Claims inputs starting with a magic prefix. +struct Magic; + +/// Replaces the built-in HTML parser. +struct PlainHtml; + +impl FormatParser for PlainHtml { + fn name(&self) -> &str { + "plain-html" + } + fn formats(&self) -> &[Format] { + &[Format::Html] + } + fn parse(&self, bytes: &[u8], _: &MarkkitBuilder) -> markkit::Result { + let text = String::from_utf8_lossy(bytes).into_owned(); + Ok(ParsedDocument::new( + Document::new(vec![Node::CodeBlock { + language: Some("html".into()), + code: text, + source: None, + }]), + Format::Html, + )) + } +} + +impl FormatParser for Magic { + fn name(&self) -> &str { + "magic" + } + fn claims(&self, bytes: &[u8], _: DocumentHint<'_>) -> bool { + bytes.starts_with(b"MAGIC1\n") + } + fn parse(&self, bytes: &[u8], _: &MarkkitBuilder) -> markkit::Result { + let body = String::from_utf8_lossy(&bytes[7..]).into_owned(); + Ok(ParsedDocument::new( + Document::new(vec![Node::heading(3, vec![Node::Text(body)])]), + Format::Unknown, + )) + } +} + +struct Panics; + +impl FormatParser for Panics { + fn name(&self) -> &str { + "panics" + } + fn formats(&self) -> &[Format] { + &[Format::Markdown] + } + fn parse(&self, _: &[u8], _: &MarkkitBuilder) -> markkit::Result { + panic!("boom") + } +} + +#[test] +fn custom_parser_for_detected_format() { + let m = Markkit::builder().parser(Csv).build(); + let e = m + .extract_document_sync(b"a,b\n1,2\n", DocumentHint::file_name("data.csv")) + .unwrap(); + assert_eq!(e.format, Format::Csv); + assert_eq!(e.markdown, "| a | b |\n| --- | --- |\n| 1 | 2 |\n"); +} + +#[test] +fn unregistered_format_still_unsupported() { + let m = Markkit::builder().build(); + let err = m + .extract_document_sync(b"a,b\n", DocumentHint::file_name("data.csv")) + .unwrap_err(); + assert!(matches!(err, Error::UnsupportedFormat(_)), "{err}"); +} + +#[test] +fn claims_run_before_detection_and_transforms_apply() { + let m = Markkit::builder() + .parser(Csv) + .parser(Magic) + .transform(markkit::transform::NormalizeHeadings) + .build(); + // A .csv name, but Magic claims the bytes first. + let e = m + .extract_document_sync(b"MAGIC1\nhello", DocumentHint::file_name("x.csv")) + .unwrap(); + assert_eq!(e.markdown, "# hello\n"); + assert_eq!(format!("{:?}", m.config().parsers), r#"["csv", "magic"]"#); +} + +#[cfg(feature = "html")] +#[test] +fn builtin_runs_when_nothing_claims() { + let m = Markkit::builder().parser(Magic).build(); + // Without a claim the built-in HTML parser still runs. + let e = m + .extract_document_sync(b"

x

", DocumentHint::content_type("text/html")) + .unwrap(); + assert_eq!(e.format, Format::Html); +} + +#[test] +fn panicking_parser_is_an_error() { + let m = Markkit::builder().parser(Panics).build(); + let err = m + .extract_document_sync(b"# x", DocumentHint::file_name("a.md")) + .unwrap_err(); + assert!( + matches!(err, Error::Parser(ref s) if s.contains("panics")), + "{err}" + ); +} + +#[test] +fn size_limit_checked_before_custom_parser() { + let m = Markkit::builder().max_input_size(4).parser(Csv).build(); + let err = m + .extract_document_sync(b"a,b,c,d", DocumentHint::file_name("x.csv")) + .unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err}"); +} + +#[test] +fn custom_parser_replaces_builtin() { + let m = Markkit::builder().parser(PlainHtml).build(); + let e = m + .extract_document_sync(b"

x

", DocumentHint::content_type("text/html")) + .unwrap(); + assert_eq!(e.markdown, "```html\n

x

\n```\n"); +} diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 7b490ee..16dc20f 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -104,6 +104,25 @@ unlabelled bytes stay `UnsupportedFormat` rather than rendering binary as text. Spreadsheet parsing re-sniffs the container (XLSX vs XLS vs ODS), so a workbook mislabelled as another Excel type still parses. +## Custom Parsers + +`MarkkitBuilder::parser(p)` registers a `registry::FormatParser` +(`name`, `formats() -> &[Format]`, `claims(bytes, hint) -> bool`, +`parse(bytes, &MarkkitBuilder) -> Result`). In +`parse_document` / `extract_document`, after the `max_input_size` check: + +1. first registered parser whose `claims` is true (new formats without a + `Format` variant), else +2. first registered parser listing the detected `Format` (fill PDF/CSV/... or + replace a built-in), else +3. the built-in parser for the detected format. + +A panic in a custom parser becomes `Error::Parser`; its errors pass through. +Transforms run on its IR and the shared renderer produces the Markdown. +Custom parsers see untrusted bytes and own their resource limits (they get +the builder, so they can honour the configured ones). `ParsedDocument::new` +is public for them. + ## DOCX Mapping | WordprocessingML | Markdown | diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index 300f7a3..3e1f993 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -28,7 +28,7 @@ It follows a pipeline architecture: **detect → parse → normalize → convert ``` Input (bytes/URL/file) → Format Detection (MIME, magic bytes, extension) - → Parser (HTML, PDF, DOCX, etc.) + → Parser (custom registry first, then HTML, DOCX, spreadsheets) → Normalized AST (intermediate representation) → Transforms (embedder IR passes, in registration order) → Markdown Converter (AST → markdown string) diff --git a/knowledge/log.md b/knowledge/log.md index 457b75d..ac7ffbb 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -13,6 +13,7 @@ ## 2026-10-26 +* **Parser registry**: `registry::FormatParser` + `MarkkitBuilder::parser`; claims, then detected-format match, then built-ins; panics become `Error::Parser`. See [Format Support](formats/format-support.md). * **Hidden content**: new threat T9 (prompt injection via text a reader never sees). HTML hidden elements and DOCX `w:vanish` runs are skipped unless `include_hidden_content(true)`; exclude selectors now also apply to list items and table rows/cells, which previously bypassed them. Invariant S20. See [Threat Model](security/threat-model.md) and [HTML Extraction](formats/html-extraction.md). * **Element rules**: declarative `HtmlRule { selector, action }` (`MarkkitBuilder::html_rule`, CLI `--rules`) tried before built-in HTML mapping; invalid selectors error. See [HTML Extraction](formats/html-extraction.md). * **Output safety**: the renderer escaped nothing, so document text could emit raw HTML, links, headings and fence breakouts, and `javascript:` links passed through. Escaping and a URL scheme allowlist now live in `render/escape.rs`; DOCX and spreadsheet parsers stop pre-escaping table pipes, so the IR holds raw text. New invariant S17 with a pulldown-cmark oracle. A first fuzzing pass against that oracle found eight more container breakouts (list indentation by marker width, blocks in inline context, adjacent code spans, nested links, ...), all fixed. Math now renders as code (`` $`tex`$ ``, ` ```math `) because TeX from page markup reached plain-GFM output unescaped. See [Threat Model](security/threat-model.md) T1. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index c56dcb7..2370058 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -449,6 +449,15 @@ style, is not detected. `html_security::hidden_content_does_not_leak_past_max_depth`, `html_integration::exclude_selector_applies_to_list_items_and_table_parts` +### Extension points (embedder code) + +Transforms, element rules and custom `FormatParser`s are embedder code or +configuration, trusted like the host program; untrusted documents cannot +register them. Untrusted bytes still reach a custom parser, so it owns its own +limits. markkit keeps its guarantees around it: `max_input_size` is checked +first, a panic becomes `Error::Parser` (with `panic = "unwind"`), and output +goes through the shared renderer. + ## Resource Limits | Resource | Default | Configurable | Enforcement Point | From 9b48adf5ffb378dd7f50d3960c49fe73fce704c9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 27 Oct 2026 02:27:08 +0000 Subject: [PATCH 2/4] test(registry): deadline and output limits cover custom parsers --- crates/markkit/tests/registry.rs | 36 +++++++++++++++++++++++++++++ knowledge/formats/format-support.md | 1 + knowledge/security/threat-model.md | 5 ++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/markkit/tests/registry.rs b/crates/markkit/tests/registry.rs index 0a49381..78ee070 100644 --- a/crates/markkit/tests/registry.rs +++ b/crates/markkit/tests/registry.rs @@ -162,3 +162,39 @@ fn custom_parser_replaces_builtin() { .unwrap(); assert_eq!(e.markdown, "```html\n

x

\n```\n"); } + +/// Sleeps past any small deadline. +struct Slow; + +impl FormatParser for Slow { + fn name(&self) -> &str { + "slow" + } + fn formats(&self) -> &[Format] { + &[Format::Csv] + } + fn parse(&self, _: &[u8], _: &MarkkitBuilder) -> markkit::Result { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(ParsedDocument::new(Document::new(vec![]), Format::Csv)) + } +} + +#[test] +fn deadline_checked_after_custom_parser() { + let limits = markkit::Limits::default().timeout(std::time::Duration::from_millis(1)); + let m = Markkit::builder().limits(limits).parser(Slow).build(); + let err = m + .extract_document_sync(b"a", DocumentHint::file_name("x.csv")) + .unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err}"); +} + +#[test] +fn output_limit_applies_to_custom_parser() { + let limits = markkit::Limits::default().max_output_size(8); + let m = Markkit::builder().limits(limits).parser(Csv).build(); + let err = m + .extract_document_sync(b"aaaa,bbbb\ncccc,dddd", DocumentHint::file_name("x.csv")) + .unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err}"); +} diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 16dc20f..21bab51 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -118,6 +118,7 @@ mislabelled as another Excel type still parses. 3. the built-in parser for the detected format. A panic in a custom parser becomes `Error::Parser`; its errors pass through. +The deadline is checked when it returns and `max_output_size` bounds the render. Transforms run on its IR and the shared renderer produces the Markdown. Custom parsers see untrusted bytes and own their resource limits (they get the builder, so they can honour the configured ones). `ParsedDocument::new` diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 2370058..881ac3f 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -455,8 +455,9 @@ Transforms, element rules and custom `FormatParser`s are embedder code or configuration, trusted like the host program; untrusted documents cannot register them. Untrusted bytes still reach a custom parser, so it owns its own limits. markkit keeps its guarantees around it: `max_input_size` is checked -first, a panic becomes `Error::Parser` (with `panic = "unwind"`), and output -goes through the shared renderer. +first, a panic becomes `Error::Parser` (with `panic = "unwind"`), the deadline +(`limits.timeout`) is checked when it returns, and output goes through the +shared renderer under `max_output_size` (`tests/registry.rs`). ## Resource Limits From ba9e372684aaaad00196672ed07d15b048816b59 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 27 Oct 2026 02:30:47 +0000 Subject: [PATCH 3/4] fix(registry): guarded is needed without office/pdf features --- crates/markkit/src/extract.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 85b76d8..ed44f86 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -145,7 +145,6 @@ impl ParsedDocument { /// Run a parser, converting a panic into an error (defence in depth for /// third-party parsers; only effective when built with `panic = "unwind"`, which /// markkit's own release profile uses). -#[cfg(any(feature = "docx", feature = "spreadsheet", feature = "pdf"))] fn guarded( what: &str, make_err: fn(String) -> Error, From 1963ef82e985324938bd8e7e92dbb609d16c0be3 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 27 Oct 2026 03:30:07 +0000 Subject: [PATCH 4/4] test(registry): PPTX as the unregistered format now that CSV is built in --- crates/markkit/tests/registry.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/markkit/tests/registry.rs b/crates/markkit/tests/registry.rs index 78ee070..d318678 100644 --- a/crates/markkit/tests/registry.rs +++ b/crates/markkit/tests/registry.rs @@ -4,7 +4,7 @@ use markkit::ir::{Document, Node, TableCell}; use markkit::registry::FormatParser; use markkit::{DocumentHint, Error, Format, Markkit, MarkkitBuilder, ParsedDocument}; -/// Naive CSV: comma-split lines, first line is the header. +/// Naive CSV: comma-split lines, first line is the header (replaces the built-in). struct Csv; impl FormatParser for Csv { @@ -100,9 +100,10 @@ fn custom_parser_for_detected_format() { #[test] fn unregistered_format_still_unsupported() { - let m = Markkit::builder().build(); + // CSV parser registered; PPTX has no built-in and no custom parser. + let m = Markkit::builder().parser(Csv).build(); let err = m - .extract_document_sync(b"a,b\n", DocumentHint::file_name("data.csv")) + .extract_document_sync(b"PK\x03\x04", DocumentHint::file_name("deck.pptx")) .unwrap_err(); assert!(matches!(err, Error::UnsupportedFormat(_)), "{err}"); }