Skip to main content

docling/
converter.rs

1//! The top-level `DocumentConverter`.
2
3use std::collections::HashSet;
4
5use crate::backend::{
6    is_deepseek_markdown, AsciiDocBackend, CsvBackend, DeclarativeBackend, DeepSeekBackend,
7    DocBackend, DoclingJsonBackend, DocxBackend, EmailBackend, EpubBackend, InterchangeBackend,
8    JatsBackend, LatexBackend, LotusBackend, MarkdownBackend, MhtmlBackend, OdfBackend, PptBackend,
9    PptxBackend, RtfBackend, StarOffice5Backend, UsptoBackend, VisioBackend, WebVttBackend,
10    XbrlBackend, XlsBackend, XlsxBackend,
11};
12
13/// Whether `text` begins with an XML prolog — an `<?xml …?>` declaration or a
14/// non-HTML `<!DOCTYPE …>`. Used to route XML documents that arrived with a
15/// text/Markdown extension (e.g. a JATS article saved as `.txt`) to the XML
16/// backends. An HTML5 `<!DOCTYPE html>` is deliberately excluded.
17fn looks_like_xml(text: &str) -> bool {
18    let head = text.trim_start();
19    if head.starts_with("<?xml") {
20        return true;
21    }
22    if let Some(rest) = head.get(..9) {
23        if rest.eq_ignore_ascii_case("<!doctype") {
24            return !head[9..]
25                .trim_start()
26                .to_ascii_lowercase()
27                .starts_with("html");
28        }
29    }
30    false
31}
32
33/// Pick the concrete XML backend for a generic `.xml` source by sniffing its
34/// DOCTYPE / root element (the first part of the file).
35fn sniff_xml(text: &str) -> InputFormat {
36    let head = &text[..text.len().min(4000)];
37    // Case-insensitive: USPTO DOCTYPE/root casing varies in the wild (docling
38    // PR #3801 — Grant Full Text v2.5 files were missed on casing).
39    let lower = head.to_ascii_lowercase();
40    if lower.contains("us-patent")
41        || lower.contains("patent-application-publication")
42        || lower.contains("patdoc")
43        || lower.contains("<pap-v1")
44    {
45        InputFormat::XmlUspto
46    } else if head.contains("<doclang") {
47        // A bare DocLang document saved as `.xml` (docling names them
48        // `*.dclg.xml`, whose final extension is plain `xml`).
49        InputFormat::XmlDoclang
50    } else if crate::backend::xbrl::looks_like_xbrl(head) {
51        InputFormat::XmlXbrl
52    } else {
53        InputFormat::XmlJats
54    }
55}
56use crate::error::ConversionError;
57use crate::format::InputFormat;
58use crate::result::{ConversionResult, ConversionStatus};
59use crate::source::SourceDocument;
60#[cfg(feature = "pdf")]
61use crate::stream::MarkdownStream;
62#[cfg(feature = "pdf")]
63use docling_core::ImageMode;
64
65/// Routes a [`SourceDocument`] to the backend for its format and returns a
66/// [`ConversionResult`].
67///
68/// The Rust analogue of `docling.document_converter.DocumentConverter`. In
69/// Phase 0 the format→backend dispatch is a direct match; the Python notion of
70/// per-format `FormatOption` (backend + pipeline + options) arrives with the
71/// PDF/ML pipeline in a later phase.
72#[derive(Debug, Clone)]
73pub struct DocumentConverter {
74    allowed_formats: Option<HashSet<InputFormat>>,
75    strict: bool,
76    fetch_images: bool,
77    list_attachments: bool,
78    no_table_former: bool,
79    no_text_panels: bool,
80    no_ocr: bool,
81    skip_ocr: bool,
82    force_full_page_ocr: bool,
83    use_web_browser: bool,
84    /// Named Whisper model preset for audio sources (docling's ASR model
85    /// specs, PR #3741): English-only / Distil-Whisper variants under
86    /// `.models/asr/<preset>/`. `None` = the default Whisper tiny.
87    asr_model: Option<String>,
88    asr_lang: Option<String>,
89    /// Max sampled frames per video (#138 Phase 2). `None` = the default
90    /// ([`DEFAULT_VIDEO_FRAMES`]); `Some(0)` disables frame extraction.
91    video_frames: Option<usize>,
92    /// Opt-in PDF/image enrichment models (docling's
93    /// `do_picture_classification` / `do_code_enrichment` /
94    /// `do_formula_enrichment`).
95    enrich: crate::EnrichmentOptions,
96    /// 1-based inclusive PDF page window (#80). See [`Self::page_range`].
97    page_range: Option<(usize, usize)>,
98    /// OCR recognition language for scanned PDF/image pages (`en`/`ch`).
99    /// `None` = the process default (`DOCLING_RS_OCR_LANG`, else English).
100    ocr_lang: Option<String>,
101    /// Directory referenced-mode streaming writes images into (#80).
102    /// See [`Self::artifacts_dir`].
103    artifacts_dir: String,
104}
105
106/// Default cap on sampled frames per video. Scene changes rarely exceed this
107/// in short clips, and uniform fallback at 8 keeps JSON/DCLX output (which
108/// embeds the PNGs) within sane bounds.
109pub const DEFAULT_VIDEO_FRAMES: usize = 8;
110
111/// Parse a user-facing page-range string (issue #80's `--pages`): `"A-B"` for
112/// an inclusive 1-based window, or a single `"N"` for one page. Whitespace
113/// around the numbers is tolerated. Validation against the actual page count
114/// happens at convert time; this only checks the spelling (`first >= 1`,
115/// `first <= last`).
116pub fn parse_page_range(s: &str) -> Result<(usize, usize), String> {
117    let parse_one = |part: &str| {
118        part.trim()
119            .parse::<usize>()
120            .map_err(|_| format!("invalid page number '{}'", part.trim()))
121    };
122    let (first, last) = match s.split_once('-') {
123        Some((a, b)) => (parse_one(a)?, parse_one(b)?),
124        None => {
125            let n = parse_one(s)?;
126            (n, n)
127        }
128    };
129    if first == 0 {
130        return Err("pages are 1-based; the range starts at 1".into());
131    }
132    if last < first {
133        return Err(format!("range {first}-{last} is inverted (first <= last)"));
134    }
135    Ok((first, last))
136}
137
138impl Default for DocumentConverter {
139    fn default() -> Self {
140        Self {
141            allowed_formats: None,
142            strict: false,
143            fetch_images: false,
144            list_attachments: false,
145            no_table_former: false,
146            no_text_panels: false,
147            no_ocr: false,
148            skip_ocr: false,
149            force_full_page_ocr: false,
150            use_web_browser: false,
151            asr_model: None,
152            asr_lang: None,
153            video_frames: None,
154            enrich: crate::EnrichmentOptions::default(),
155            page_range: None,
156            ocr_lang: None,
157            artifacts_dir: "artifacts".to_string(),
158        }
159    }
160}
161
162impl DocumentConverter {
163    /// A converter that accepts every supported format.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// A converter restricted to an explicit set of formats. Sources of any
169    /// other format are rejected with [`ConversionError::UnsupportedFormat`].
170    pub fn with_allowed_formats(formats: impl IntoIterator<Item = InputFormat>) -> Self {
171        Self {
172            allowed_formats: Some(formats.into_iter().collect()),
173            ..Self::default()
174        }
175    }
176
177    /// Convert only PDF pages `first..=last` (**1-based** inclusive, the page
178    /// numbers a viewer shows — issue #80's `--pages A-B`). Out-of-window pages
179    /// are skipped before rasterization, so converting 3 pages of a 500-page
180    /// PDF costs 3 pages. `last` clamps to the document; a window that selects
181    /// no pages at all errors at convert time. Non-PDF formats ignore the
182    /// window (they convert whole).
183    pub fn page_range(mut self, first: usize, last: usize) -> Self {
184        self.page_range = Some((first, last));
185        self
186    }
187
188    /// OCR recognition language for scanned PDF/image pages: `"en"` (the
189    /// default — English PP-OCRv3, proper Latin word spacing) or `"ch"` (the
190    /// multilingual model docling conformance is measured with — glues Latin
191    /// words). An unknown value warns at conversion time and uses the
192    /// default; explicit `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win
193    /// over this switch. Formats that never OCR ignore it.
194    pub fn ocr_lang(mut self, lang: impl Into<String>) -> Self {
195        self.ocr_lang = Some(lang.into());
196        self
197    }
198
199    /// The configured ML pipeline for one conversion (models load per call —
200    /// callers that convert many files hold a warm [`docling_pdf::Pipeline`]
201    /// themselves). Grew out of docling-pdf's `convert_with_options` free
202    /// functions, whose fixed signatures couldn't take #244's `skip_ocr`.
203    #[cfg(feature = "pdf")]
204    fn ml_pipeline(&self) -> Result<docling_pdf::Pipeline, docling_pdf::PdfError> {
205        Ok(docling_pdf::Pipeline::new()?
206            .no_table_former(self.no_table_former)
207            .no_ocr(self.no_ocr)
208            .skip_ocr(self.skip_ocr)
209            .no_text_panels(self.no_text_panels)
210            .enrichments(self.enrich)
211            .ocr_lang(self.ocr_lang_choice()))
212    }
213
214    /// The parsed [`Self::ocr_lang`] choice for the ML call sites; a value
215    /// that parses to nothing warns here (once per conversion) rather than
216    /// erroring — same degradation the env selector applies.
217    #[cfg(feature = "pdf")]
218    fn ocr_lang_choice(&self) -> Option<docling_pdf::OcrLang> {
219        let raw = self.ocr_lang.as_deref()?;
220        let parsed = docling_pdf::OcrLang::parse(raw);
221        if parsed.is_none() {
222            eprintln!("docling: ocr_lang {raw:?} is not en|ch; using the default");
223        }
224        parsed
225    }
226
227    /// Where [`ImageMode::Referenced`] streaming writes image files, and the
228    /// link prefix used in the Markdown (default `artifacts`, matching the
229    /// buffered export's convention). Relative paths resolve against the
230    /// process working directory.
231    pub fn artifacts_dir(mut self, dir: impl Into<String>) -> Self {
232        self.artifacts_dir = dir.into();
233        self
234    }
235
236    /// Cap the number of frames sampled from a video (#138 Phase 2); `0`
237    /// disables frame extraction entirely (Phase 1 behavior: transcript only).
238    /// Defaults to [`DEFAULT_VIDEO_FRAMES`]. Frames are extracted with the
239    /// `ffmpeg` binary when present (`DOCLING_FFMPEG` overrides the path);
240    /// without it a video converts to its transcript alone.
241    pub fn video_frames(mut self, max: usize) -> Self {
242        self.video_frames = Some(max);
243        self
244    }
245
246    /// Select a named Whisper model preset for audio sources — the
247    /// English-only (`whisper_tiny_en`, `whisper_base_en`, `whisper_small_en`)
248    /// and Distil-Whisper (`whisper_distil_small_en`) variants of docling's
249    /// ASR model specs. `None` (default) uses Whisper tiny (multilingual)
250    /// from `.models/asr/`; presets load from `.models/asr/<preset>/` (fetch
251    /// them with `download_dependencies.sh --asr-model <preset>`).
252    pub fn asr_model(mut self, model: Option<String>) -> Self {
253        self.asr_model = model;
254        self
255    }
256
257    /// Select the ASR transcription language for audio/video sources: a
258    /// Whisper code (`en`, `de`, `zh`, …) or `auto`. `None` (default) falls
259    /// back to `DOCLING_RS_ASR_LANG`, and — when that is unset too — to
260    /// per-file auto-detection from the first 30-second window (docling
261    /// 2.116 parity). English-only presets always transcribe English.
262    pub fn asr_lang(mut self, lang: Option<String>) -> Self {
263        self.asr_lang = lang;
264        self
265    }
266
267    /// Select the Markdown export mode for documents this converter produces.
268    ///
269    /// `false` (default) makes [`crate::DoclingDocument::export_to_markdown`]
270    /// reproduce docling's legacy output byte-for-byte; `true` makes it emit
271    /// cleaner, more conformant Markdown (code-fence languages preserved, no
272    /// inline-run spacing artifacts, no entity re-escaping). Rust-only — Python
273    /// docling has no such switch.
274    pub fn strict(mut self, strict: bool) -> Self {
275        self.strict = strict;
276        self
277    }
278
279    /// Fetch and embed external `<img>` images for HTML/EPUB sources.
280    ///
281    /// Off by default (matching docling's `enable_*_fetch=False`), so output is
282    /// unchanged unless you opt in. When on, the HTML/EPUB backends resolve each
283    /// `<img src>` — `data:` URIs, local files (relative to the source file's
284    /// directory), `http(s)` URLs, and EPUB archive entries — and embed the
285    /// bytes, so they survive into JSON `ImageRef`s and
286    /// [`crate::DoclingDocument::export_to_markdown_with_images`].
287    ///
288    /// Remote `http(s)` URLs are fetched over the network; enable only for input
289    /// you trust (it can otherwise be used to make the process issue requests).
290    pub fn fetch_images(mut self, fetch: bool) -> Self {
291        self.fetch_images = fetch;
292        self
293    }
294
295    /// Append an `Attachments` section to converted emails (`.eml` / `.msg`):
296    /// one list item per attachment, `name (content/type)` — names and types
297    /// only, the payload is never embedded. docling's opt-in
298    /// `EmailBackendOptions.list_attachments` (#251); off by default.
299    pub fn list_attachments(mut self, list: bool) -> Self {
300        self.list_attachments = list;
301        self
302    }
303
304    /// Skip loading and running the TableFormer table-structure model for
305    /// PDF/image/METS sources.
306    ///
307    /// Off by default. When enabled, table regions are still detected and
308    /// emitted, but their structure is reconstructed geometrically from cell
309    /// positions instead of the ONNX model's predicted structure — no model
310    /// load and no per-table inference, at the cost of table fidelity. Useful
311    /// when parsing speed matters more than exact table structure, especially
312    /// with [`convert_streaming`](Self::convert_streaming).
313    pub fn no_table_former(mut self, disable: bool) -> Self {
314        self.no_table_former = disable;
315        self
316    }
317
318    /// PDF/image: keep every detected picture as a picture — disable the
319    /// text-panel demotion that turns an uncaptioned, dense text-panel
320    /// "picture" into paragraphs (#157). The escape hatch for
321    /// image-extraction workflows and for charts the heuristic might still
322    /// misjudge on scanned pages (#173).
323    pub fn no_text_panels(mut self, disable: bool) -> Self {
324        self.no_text_panels = disable;
325        self
326    }
327
328    /// Skip layout detection, OCR, and TableFormer entirely for PDF/image/METS
329    /// sources — no model load, no inference of any kind.
330    ///
331    /// Off by default. When enabled, the PDF's embedded text cells are grouped by
332    /// line and emitted as plain paragraphs in reading order: no headings, lists,
333    /// tables, code blocks, or pictures, since that structure comes from the
334    /// layout model. The fastest possible PDF path, but pages with no embedded
335    /// text layer (scanned/image-only PDFs) yield no text at all — convert those
336    /// without this flag. Implies [`no_table_former`](Self::no_table_former).
337    pub fn no_ocr(mut self, disable: bool) -> Self {
338        self.no_ocr = disable;
339        self
340    }
341
342    /// Never run OCR, but keep layout detection and TableFormer — docling's
343    /// independent `do_ocr=False` (#244), the counterpart of
344    /// [`no_table_former`](Self::no_table_former). Unlike
345    /// [`no_ocr`](Self::no_ocr) (the skip-everything fast path), structured
346    /// output — headings, tables, pictures, reading order — is preserved; only
347    /// text that exists solely as pixels is lost (scanned pages come back with
348    /// empty regions, and the speculative OCR of large embedded images never
349    /// runs). The OCR model is never loaded, and independently of this flag a
350    /// *missing* OCR model now degrades to the same behavior with a warning
351    /// instead of failing the conversion. SVG inputs route to direct
352    /// `<text>` extraction (their text is native — skipping OCR must not lose
353    /// it), like `no_ocr`.
354    pub fn skip_ocr(mut self, disable: bool) -> Self {
355        self.skip_ocr = disable;
356        self
357    }
358
359    /// OCR every PDF page from its rendered image even when the page carries
360    /// an embedded text layer — docling's `force_full_page_ocr`. The escape
361    /// hatch for text layers that exist but lie (broken encodings, subset
362    /// fonts with garbage mappings, scanned forms with a few typed-in
363    /// fields). Off by default; ignored when [`no_ocr`](Self::no_ocr) is set,
364    /// mirroring docling, where it is a sub-option of `do_ocr`. Applies to
365    /// PDFs only — standalone images are always OCR'd.
366    pub fn force_full_page_ocr(mut self, force: bool) -> Self {
367        self.force_full_page_ocr = force;
368        self
369    }
370
371    /// Classify each detected picture with the DocumentFigureClassifier model
372    /// (docling's `do_picture_classification`). Off by default.
373    ///
374    /// The full 26-class prediction distribution (bar_chart, logo, signature,
375    /// …) lands on the picture item and is serialized into the docling JSON as
376    /// the `classification` annotation plus the `meta.classification` field.
377    /// Markdown output is unaffected. Needs `.models/picture_classifier.onnx`
378    /// (fetched by `scripts/install/download_dependencies.sh`); a missing
379    /// model warns once and skips classification.
380    pub fn do_picture_classification(mut self, enable: bool) -> Self {
381        self.enrich.picture_classification = enable;
382        self
383    }
384
385    /// Rewrite detected code blocks with the CodeFormulaV2 VLM (docling's
386    /// `do_code_enrichment`). Off by default.
387    ///
388    /// The model re-reads the code crop at ~120 dpi, emits the clean source
389    /// text (line breaks included) and identifies the language, which lands in
390    /// the JSON `code_language` field. Needs the `.models/code_formula/` graphs
391    /// (fetched by `scripts/install/download_dependencies.sh`); a missing
392    /// model warns once and leaves the block as extracted.
393    pub fn do_code_enrichment(mut self, enable: bool) -> Self {
394        self.enrich.code = enable;
395        self
396    }
397
398    /// Decode display formulas to LaTeX with the CodeFormulaV2 VLM (docling's
399    /// `do_formula_enrichment`). Off by default.
400    ///
401    /// An enriched formula renders as `$$latex$$` in Markdown and as a
402    /// `formula` text item in the JSON, replacing the
403    /// `<!-- formula-not-decoded -->` placeholder. Same model artifacts as
404    /// [`do_code_enrichment`](Self::do_code_enrichment).
405    pub fn do_formula_enrichment(mut self, enable: bool) -> Self {
406        self.enrich.formula = enable;
407        self
408    }
409
410    /// Pre-render HTML-routing input in a headless browser before parsing.
411    ///
412    /// Off by default. When enabled, HTML sources — and MHTML/EPUB, which
413    /// assemble HTML from their archives — are loaded in the system Chromium
414    /// (driven from Rust over the DevTools protocol — no Node/Playwright) so the
415    /// CSS cascade is resolved: elements the browser computes as `display:none`
416    /// (e.g. a stylesheet-collapsed nav menu) are removed before the normal HTML
417    /// backend runs. This is the one behaviour a pure-Rust parse can't reproduce;
418    /// everything else (structure, tables, KVP, formatting) is still handled in
419    /// Rust on the cleaned HTML.
420    ///
421    /// Requires the crate's `web-browser` Cargo feature; without it, converting
422    /// an HTML source with this enabled returns [`ConversionError::Browser`].
423    pub fn use_web_browser(mut self, enable: bool) -> Self {
424        self.use_web_browser = enable;
425        self
426    }
427
428    /// Return `html` unchanged, or — when [`use_web_browser`](Self::use_web_browser)
429    /// is on — its headless-browser-cleaned form (computed-hidden elements
430    /// removed). Borrows in the common (disabled) case; only allocates when the
431    /// browser actually runs.
432    fn maybe_prerender<'a>(
433        &self,
434        html: &'a str,
435    ) -> Result<std::borrow::Cow<'a, str>, ConversionError> {
436        crate::backend::maybe_prerender_html(html, self.use_web_browser)
437    }
438
439    /// Convert a source document to Markdown **incrementally**, returning an
440    /// iterator of Markdown chunks (with picture placeholders).
441    ///
442    /// Concatenating every `Ok` chunk reproduces
443    /// [`convert`](Self::convert)`(...).document.export_to_markdown()`
444    /// byte-for-byte. The win is for PDF, whose pages are processed in parallel:
445    /// each page's Markdown is emitted in document order as soon as it is ready, so
446    /// output starts before the whole document is converted. Other formats build
447    /// their document up front and stream it through the same interface.
448    ///
449    /// Streaming is Markdown-only — JSON needs the whole node tree, so there is no
450    /// streaming JSON. The conversion runs on a background thread; dropping the
451    /// returned [`MarkdownStream`] cancels it.
452    #[cfg(feature = "pdf")]
453    pub fn convert_streaming(
454        &self,
455        source: SourceDocument,
456    ) -> Result<MarkdownStream, ConversionError> {
457        self.convert_streaming_images(source, ImageMode::Placeholder)
458    }
459
460    /// Like [`convert_streaming`](Self::convert_streaming) but with an explicit
461    /// picture [`ImageMode`].
462    ///
463    /// [`ImageMode::Referenced`] streams too (issue #80): each page's images
464    /// are written to [`artifacts_dir`](Self::artifacts_dir) *as the page's
465    /// Markdown is emitted* and dropped from memory, so an image-heavy PDF
466    /// holds ~one page of images at a time instead of all of them until
467    /// export. The chunks and files match the buffered
468    /// `export_to_markdown_with_images(ImageMode::Referenced, ..)` output.
469    #[cfg(feature = "pdf")]
470    pub fn convert_streaming_images(
471        &self,
472        source: SourceDocument,
473        image_mode: ImageMode,
474    ) -> Result<MarkdownStream, ConversionError> {
475        if let Some(allowed) = &self.allowed_formats {
476            if !allowed.contains(&source.format) {
477                return Err(ConversionError::UnsupportedFormat(source.format));
478            }
479        }
480        Ok(crate::stream::spawn(self.clone(), source, image_mode))
481    }
482
483    /// Streaming internals ([`crate::stream`]) read the producer's settings
484    /// off the converter clone they receive.
485    #[cfg(feature = "pdf")]
486    pub(crate) fn stream_settings(&self) -> crate::stream::StreamSettings {
487        crate::stream::StreamSettings {
488            strict: self.strict,
489            no_table_former: self.no_table_former,
490            no_text_panels: self.no_text_panels,
491            no_ocr: self.no_ocr,
492            skip_ocr: self.skip_ocr,
493            force_full_page_ocr: self.force_full_page_ocr,
494            enrich: self.enrich,
495            page_range: self.page_range,
496            ocr_lang: self.ocr_lang_choice(),
497            artifacts_dir: self.artifacts_dir.clone(),
498        }
499    }
500
501    /// Convert a single source document.
502    pub fn convert(&self, source: SourceDocument) -> Result<ConversionResult, ConversionError> {
503        if let Some(allowed) = &self.allowed_formats {
504            if !allowed.contains(&source.format) {
505                return Err(ConversionError::UnsupportedFormat(source.format));
506            }
507        }
508
509        let mut document = match source.format {
510            // A legacy APS (Automated Patent System) plain-text patent (`PATN`
511            // first record) is reconstructed verbatim, mirroring docling.
512            InputFormat::Md if crate::backend::uspto::looks_like_aps(source.text()?) => {
513                crate::backend::uspto::convert_aps(&source)?
514            }
515            // A text/Markdown-typed file that is actually an XML document (e.g. a
516            // JATS article saved with a `.txt` extension) routes to the XML
517            // backends by content, mirroring docling's content-based detection.
518            InputFormat::Md if looks_like_xml(source.text()?) => match sniff_xml(source.text()?) {
519                InputFormat::XmlUspto => UsptoBackend.convert(&source)?,
520                InputFormat::XmlXbrl => XbrlBackend.convert(&source)?,
521                // A JATS/other XML document saved as `.txt` is reconstructed
522                // generically (element-by-element), as docling does — the
523                // semantic JATS backend is only used for real `.xml`/`.nxml`.
524                _ => crate::backend::jats::convert_generic(&source)?,
525            },
526            // DeepSeek-OCR annotated Markdown (VLM token format) is detected by
527            // its `<|ref|>…[[bbox]]` annotations and parsed separately.
528            InputFormat::Md if is_deepseek_markdown(source.text()?) => {
529                DeepSeekBackend.convert(&source)?
530            }
531            InputFormat::Md => MarkdownBackend {
532                strict: self.strict,
533            }
534            .convert(&source)?,
535            InputFormat::Csv => CsvBackend.convert(&source)?,
536            InputFormat::Html => {
537                // Optionally resolve the CSS cascade in a headless browser first
538                // (strips computed-hidden elements); everything else stays in the
539                // Rust HTML backend, which runs on the cleaned HTML.
540                let html = self.maybe_prerender(source.text()?)?;
541                if self.fetch_images {
542                    let resolver = crate::backend::FsImageResolver::new(
543                        source.base_dir().map(|p| p.to_path_buf()),
544                        source.base_url.clone(),
545                    );
546                    crate::backend::convert_html(&source.name, &html, &resolver)
547                } else {
548                    crate::backend::convert_html(&source.name, &html, &crate::backend::NoFetch)
549                }
550            }
551            InputFormat::Asciidoc => AsciiDocBackend.convert(&source)?,
552            InputFormat::Xlsx => XlsxBackend.convert(&source)?,
553            InputFormat::Pptx => PptxBackend.convert(&source)?,
554            // RTF (#209): a docling.rs extension — docling reaches RTF only via
555            // LibreOffice; here it parses natively (hand-rolled tokenizer).
556            InputFormat::Rtf => RtfBackend.convert(&source)?,
557            InputFormat::Visio => VisioBackend.convert(&source)?,
558            // StarOffice 5 binaries (#215): docling.rs extension, native CFB
559            // parse (docling would go through LibreOffice).
560            InputFormat::StarOffice5 => StarOffice5Backend.convert(&source)?,
561            // DIF/SYLK/dBase (#216): docling.rs extensions, one content-sniffing
562            // backend for the three table relics.
563            InputFormat::Dbf | InputFormat::Dif | InputFormat::Sylk => {
564                InterchangeBackend.convert(&source)?
565            }
566            // Lotus/Quattro/Works record streams (#216): one BOF-sniffing
567            // backend for the whole DOS-era family.
568            InputFormat::Lotus => LotusBackend.convert(&source)?,
569            InputFormat::Docx => DocxBackend.convert(&source)?,
570            // Legacy binary Office (issue #127): parsed natively — docling
571            // proper converts these through LibreOffice first (PR #3804).
572            InputFormat::Xls => XlsBackend.convert(&source)?,
573            InputFormat::Ppt => PptBackend.convert(&source)?,
574            InputFormat::Doc => DocBackend.convert(&source)?,
575            InputFormat::Vtt => WebVttBackend.convert(&source)?,
576            InputFormat::Email => EmailBackend {
577                list_attachments: self.list_attachments,
578            }
579            .convert(&source)?,
580            InputFormat::Mhtml => MhtmlBackend {
581                use_web_browser: self.use_web_browser,
582            }
583            .convert(&source)?,
584            InputFormat::Epub => EpubBackend {
585                fetch_images: self.fetch_images,
586                use_web_browser: self.use_web_browser,
587            }
588            .convert(&source)?,
589            InputFormat::JsonDocling => DoclingJsonBackend.convert(&source)?,
590            InputFormat::Latex => LatexBackend.convert(&source)?,
591            // A bare `.xml` defaults to XmlJats; sniff the content to route to the
592            // right XML backend (docling distinguishes by DOCTYPE / root element).
593            InputFormat::XmlJats | InputFormat::XmlUspto | InputFormat::XmlXbrl => {
594                match sniff_xml(source.text()?) {
595                    InputFormat::XmlUspto => UsptoBackend.convert(&source)?,
596                    InputFormat::XmlXbrl => XbrlBackend.convert(&source)?,
597                    _ => JatsBackend.convert(&source)?,
598                }
599            }
600            InputFormat::Odt | InputFormat::Ods | InputFormat::Odp => {
601                OdfBackend.convert(&source)?
602            }
603            // DocLang back in: bare XML (`.dclg`/`.dclg.xml`) or the OPC
604            // archive `--to dclx` writes.
605            InputFormat::XmlDoclang | InputFormat::Dclx => {
606                crate::backend::DoclangBackend.convert(&source)?
607            }
608            // Raw DocTags (VLM token markup, #152): the tolerant docling-core
609            // parser — never fails, best-effort document out.
610            InputFormat::DocTags => {
611                let mut doc = docling_core::doctags::parse(source.text()?);
612                doc.name = source.name.clone();
613                doc
614            }
615            #[cfg(feature = "pdf")]
616            InputFormat::Pdf => self
617                .ml_pipeline()
618                .map(|p| {
619                    p.force_full_page_ocr(self.force_full_page_ocr)
620                        .pages(self.page_range)
621                })
622                .and_then(|mut p| p.convert(&source.bytes, None, &source.name))
623                .map_err(|e| ConversionError::with_source("pdf", e))?,
624            // SVG (#212), the ML route: rasterize (resvg, white-backed PNG at
625            // ~2048px long side) and ride the image pipeline. `--no-ocr` short-
626            // circuits to direct <text> extraction instead — the SVG carries
627            // its text natively, so skipping OCR must not mean losing it.
628            #[cfg(feature = "pdf")]
629            InputFormat::Svg if !self.no_ocr && !self.skip_ocr => {
630                let png = crate::backend::svg::rasterize_png(&source.bytes)?;
631                self.ml_pipeline()
632                    .and_then(|mut p| p.convert_image(&png, &source.name))
633                    .map_err(|e| ConversionError::with_source("svg", e))?
634            }
635            // SVG without the ML pipeline (pdf-text / wasm builds) or with
636            // --no-ocr / --skip-ocr: pure-Rust <text> extraction, flat
637            // paragraphs in reading order (the pdf / pdf-text split, applied
638            // to SVG) — the SVG carries its text natively, so skipping OCR
639            // must not mean losing it.
640            InputFormat::Svg => crate::backend::SvgBackend.convert(&source)?,
641            // Apple iWork (#213): pure-Rust IWA text extraction, all builds.
642            InputFormat::Pages | InputFormat::Numbers | InputFormat::Keynote => {
643                crate::backend::IworkBackend.convert(&source)?
644            }
645            #[cfg(feature = "pdf")]
646            InputFormat::Image => self
647                .ml_pipeline()
648                .and_then(|mut p| p.convert_image(&source.bytes, &source.name))
649                .map_err(|e| ConversionError::with_source("image", e))?,
650            #[cfg(feature = "pdf")]
651            InputFormat::MetsGbs => self
652                .ml_pipeline()
653                .and_then(|mut p| {
654                    docling_pdf::convert_mets_gbs_with_pipeline(&source.bytes, &source.name, &mut p)
655                })
656                .map_err(|e| ConversionError::with_source("mets-gbs", e))?,
657            // Audio → Whisper ASR (symphonia decode + ONNX inference); each
658            // transcribed segment becomes a `[time: start-end] text` paragraph.
659            #[cfg(feature = "asr")]
660            InputFormat::Audio => docling_asr::convert_audio_with_options(
661                &source.bytes,
662                &source.name,
663                self.asr_model.as_deref(),
664                self.asr_lang.as_deref(),
665            )
666            .map_err(|e| ConversionError::with_source(source.format.as_str(), e))?,
667            // Video (#138): the audio track transcribes through the same ASR
668            // path (Phase 1), and — when the ffmpeg binary is available —
669            // sampled frames interleave with the transcript as timestamped
670            // pictures (Phase 2). Without ffmpeg: transcript only.
671            #[cfg(feature = "asr")]
672            InputFormat::Video => crate::video::convert_video(
673                &source.bytes,
674                &source.name,
675                self.asr_model.as_deref(),
676                self.asr_lang.as_deref(),
677                self.video_frames.unwrap_or(DEFAULT_VIDEO_FRAMES),
678            )
679            .map_err(|e| ConversionError::with_source(source.format.as_str(), e))?,
680            // Without the full ML pipeline, `pdf-text` still converts a PDF's
681            // embedded text layer (pure Rust — the wasm32 path), equivalent to
682            // `--no-ocr`: flat paragraphs, no headings/tables/pictures. A
683            // scanned PDF has no text layer, so an empty document means "this
684            // needs OCR" — say so instead of returning nothing.
685            #[cfg(all(feature = "pdf-text", not(feature = "pdf")))]
686            InputFormat::Pdf => {
687                let doc = docling_pdf::convert_text_layer_pages(
688                    &source.bytes,
689                    &source.name,
690                    self.page_range,
691                )
692                .map_err(|e| ConversionError::with_source("pdf", e))?;
693                if doc.nodes.is_empty() {
694                    return Err(ConversionError::Parse(
695                        "PDF has no embedded text layer (scanned/image-only?); OCR needs a \
696                         build with the `pdf` feature"
697                            .into(),
698                    ));
699                }
700                doc
701            }
702            // Compiled without the ML pipelines: the formats stay detectable,
703            // but converting them needs a build with the matching feature.
704            #[cfg(not(any(feature = "pdf", feature = "pdf-text")))]
705            InputFormat::Pdf => {
706                return Err(ConversionError::Parse(
707                    "Pdf conversion is not compiled in (rebuild with the `pdf` feature, or \
708                     `pdf-text` for text-layer-only extraction)"
709                        .into(),
710                ))
711            }
712            #[cfg(not(feature = "pdf"))]
713            InputFormat::Image | InputFormat::MetsGbs => {
714                return Err(ConversionError::Parse(format!(
715                    "{:?} conversion is not compiled in (rebuild with the `pdf` feature)",
716                    source.format
717                )))
718            }
719            #[cfg(not(feature = "asr"))]
720            InputFormat::Audio | InputFormat::Video => {
721                return Err(ConversionError::Parse(format!(
722                    "{} conversion is not compiled in (rebuild with the `asr` feature)",
723                    source.format.as_str()
724                )))
725            }
726        };
727        // Carry the mode so `result.document.export_to_markdown()` reflects it.
728        document.strict_markdown = self.strict;
729        // First-class cells for every table (#240): backends with page
730        // geometry (the PDF TableFormer paths) set them; everything else —
731        // declarative tables included — derives them from the grid plus the
732        // structure overlay (real spans for DOCX/XLSX merges, HTML `th`
733        // headers, ODF covered cells; 1×1 records otherwise), so the repair
734        // API and the JSON `table_cells` are populated uniformly.
735        for table in document.tables_mut() {
736            if table.cells.is_none() {
737                table.cells = Some(table.derive_cells());
738            }
739        }
740
741        Ok(ConversionResult {
742            document,
743            status: ConversionStatus::Success,
744            input_name: source.name,
745            format: source.format,
746        })
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn end_to_end_markdown() {
756        let src =
757            SourceDocument::from_bytes("doc", InputFormat::Md, b"# Hello\n\nWorld.\n".to_vec());
758        let result = DocumentConverter::new().convert(src).unwrap();
759        assert_eq!(result.status, ConversionStatus::Success);
760        assert_eq!(result.document.export_to_markdown(), "# Hello\n\nWorld.\n");
761    }
762
763    #[test]
764    fn doctags_input_converts() {
765        // Raw DocTags markup (#152) — the VLM token stream — as a first-class
766        // input format (.doctags/.dt), through the tolerant docling-core
767        // parser.
768        let markup = b"<doctag><section_header_level_1><loc_1><loc_2><loc_3><loc_4>Intro</section_header_level_1><text>Body.</text></doctag>"
769            .to_vec();
770        let src = SourceDocument::from_bytes("page.doctags", InputFormat::DocTags, markup);
771        let result = DocumentConverter::new().convert(src).unwrap();
772        let md = result.document.export_to_markdown();
773        assert!(md.contains("## Intro"), "{md}");
774        assert!(md.contains("Body."), "{md}");
775    }
776
777    #[test]
778    fn doclang_xml_round_trips() {
779        // Every input format now has a backend; DocLang XML reads back in and
780        // re-exports as Markdown.
781        let xml = b"<doclang version=\"0.7\">\n  <heading>Title</heading>\n  \
782                    <text>Hello <bold>world</bold></text>\n</doclang>"
783            .to_vec();
784        let src = SourceDocument::from_bytes("doc.dclg", InputFormat::XmlDoclang, xml);
785        let result = DocumentConverter::new().convert(src).unwrap();
786        let md = result.document.export_to_markdown();
787        assert!(md.contains("# Title"), "{md}");
788        assert!(md.contains("**world**"), "{md}");
789    }
790
791    #[test]
792    fn sniffs_uspto_doctype_case_insensitively() {
793        // docling PR #3801: Grant Full Text v2.5 files were missed when the
794        // DOCTYPE casing differed.
795        for head in [
796            "<?xml version=\"1.0\"?><!DOCTYPE PATDOC SYSTEM \"ST32-US-Grant-025xml.dtd\"><PATDOC/>",
797            "<?xml version=\"1.0\"?><!DOCTYPE patdoc SYSTEM \"st32-us-grant-025xml.dtd\"><patdoc/>",
798            "<?xml version=\"1.0\"?><US-PATENT-GRANT-V4/>",
799        ] {
800            assert_eq!(
801                super::sniff_xml(head),
802                InputFormat::XmlUspto,
803                "head: {head}"
804            );
805        }
806    }
807}