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}