docling_pdf/lib.rs
1//! PDF backend for docling.rs.
2//!
3//! A port of docling's standard PDF pipeline: pdfium extracts the text layer
4//! (cells with bounding boxes) and renders page images; a discriminative ONNX
5//! stack (layout detection, table structure, OCR) classifies regions; the cells
6//! are assembled in reading order into a [`DoclingDocument`].
7//!
8//! Current stages: pdfium text-cell extraction + page rendering ([`pdfium_backend`])
9//! and the deterministic text/reading-order assembly ([`assemble`]). The layout,
10//! table-structure and OCR ONNX stages land behind [`Pipeline`] next.
11
12// Without `ml` only the text-layer path runs; the shared assembly/label
13// helpers it doesn't exercise stay compiled for API stability (the full
14// build still flags genuinely dead code).
15#![cfg_attr(not(feature = "ml"), allow(dead_code))]
16
17// Reading-order assembly. Public under `ocr-prep` so the browser pipeline can
18// reuse the geometric table reconstruction and its reliability gate (#157).
19#[cfg(feature = "ocr-prep")]
20pub mod assemble;
21#[cfg(not(feature = "ocr-prep"))]
22mod assemble;
23mod dp_lines;
24#[cfg(feature = "ml")]
25pub mod enrich;
26// Public so sibling crates (e.g. docling-rag's ONNX embedder) can route their
27// own `ort` sessions through the same `DOCLING_RS_EP` selection.
28#[cfg(feature = "ml")]
29pub mod ep;
30pub mod layout;
31#[cfg(feature = "ml")]
32mod mets;
33#[cfg(feature = "ml")]
34mod ocr;
35#[cfg(feature = "ocr-prep")]
36pub mod ocr_prep;
37pub mod pdfium_backend;
38mod reading_order;
39// Pure-Rust region resampling (page→1024px box-average, crop→448 bilinear) —
40// available to the browser TableFormer path (#157 stage 3), not just `ml`.
41#[cfg(feature = "ocr-prep")]
42pub mod resample;
43#[cfg(feature = "ocr-prep")]
44pub mod scanned;
45#[cfg(feature = "ml")]
46pub mod tableformer;
47pub mod textparse;
48#[cfg(feature = "ocr-prep")]
49pub mod tf_core;
50// docling's TableFormer cell matcher — pure Rust, shared with the browser
51// TableFormer path (#157 stage 3).
52#[cfg(feature = "ocr-prep")]
53pub mod tf_match;
54pub mod timing;
55
56#[cfg(feature = "ml")]
57use std::collections::BTreeMap;
58use std::fmt;
59#[cfg(feature = "ml")]
60use std::sync::mpsc::{sync_channel, Receiver};
61#[cfg(feature = "ml")]
62use std::sync::{Arc, Mutex};
63
64use docling_core::DoclingDocument;
65#[cfg(feature = "ml")]
66use docling_core::Node;
67
68#[cfg(feature = "ml")]
69pub use mets::{convert_mets_gbs, convert_mets_gbs_with_options};
70#[cfg(feature = "ml")]
71pub use ocr::OcrLang;
72#[cfg(feature = "ml")]
73pub use pdfium_backend::PdfDocument;
74pub use pdfium_backend::{PdfPage, TextCell};
75
76/// Errors from the PDF backend. Detailed and surfaced (never silently skipped).
77#[derive(Debug)]
78pub enum PdfError {
79 /// pdfium failed to bind, open, or read the document.
80 Pdfium(String),
81 /// The layout ONNX model failed to load or run.
82 Layout(String),
83 /// The OCR ONNX model failed to load or run.
84 Ocr(String),
85}
86
87impl fmt::Display for PdfError {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self {
90 PdfError::Pdfium(m) => write!(f, "pdf: pdfium error: {m}"),
91 PdfError::Layout(m) => write!(f, "pdf: {m}"),
92 PdfError::Ocr(m) => write!(f, "pdf: {m}"),
93 }
94 }
95}
96
97impl std::error::Error for PdfError {}
98
99#[cfg(feature = "ml")]
100impl From<pdfium_render::prelude::PdfiumError> for PdfError {
101 fn from(e: pdfium_render::prelude::PdfiumError) -> Self {
102 PdfError::Pdfium(e.to_string())
103 }
104}
105
106/// Convert a PDF's **embedded text layer only** — no pdfium, no ONNX, no
107/// threads: the pure-Rust content-stream parser ([`textparse`]) feeds the same
108/// orphan-region assembly the `no_ocr` pipeline flag uses, so text-layer PDFs
109/// come out identical to `--no-ocr` (flat, line-grouped paragraphs in reading
110/// order; no headings/lists/tables/pictures, and no hyperlink recovery).
111///
112/// This is the only conversion entry compiled without the `ml` feature (it is
113/// what a wasm32 build runs). A scanned/image-only PDF (no embedded text
114/// layer) yields an empty document rather than an error, same as `no_ocr` —
115/// callers can detect that and fall back to an OCR-capable build.
116pub fn convert_text_layer(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
117 convert_text_layer_pages(bytes, name, None)
118}
119
120/// [`convert_text_layer`] restricted to a **1-based inclusive** page window
121/// (issue #80's `--pages`); `None` converts everything. The window is
122/// validated the same way as [`Pipeline::pages`]: `first <= last`, 1-based,
123/// and it must select at least one existing page.
124pub fn convert_text_layer_pages(
125 bytes: &[u8],
126 name: &str,
127 pages: Option<(usize, usize)>,
128) -> Result<DoclingDocument, PdfError> {
129 if let Some((first, last)) = pages {
130 if first == 0 || last < first {
131 return Err(PdfError::Pdfium(format!(
132 "invalid page range {first}-{last} (pages are 1-based, first <= last)"
133 )));
134 }
135 }
136 let mut doc = DoclingDocument::new(name);
137 let mut total = 0usize;
138 let parsed = textparse::pdf_text_pages(bytes);
139 // A vestigial layer (a few typed-in form fields over scanned pages) is not
140 // the document's text: return the empty document, which callers already
141 // report as "no text layer" — so an OCR-capable caller falls back to OCR
142 // instead of proudly extracting thirteen characters.
143 if textparse::text_layer_is_vestigial(&parsed) {
144 return Ok(doc);
145 }
146 for (i, page) in parsed.into_iter().enumerate() {
147 total += 1;
148 if let Some((first, last)) = pages {
149 if i + 1 < first || i + 1 > last {
150 continue;
151 }
152 }
153 let mut regions = Vec::new();
154 assemble::add_orphan_regions(&mut regions, &page.cells);
155 let table_rows = vec![None; regions.len()];
156 let enrich_out = vec![None; regions.len()];
157 let (mut nodes, links) = assemble::assemble_page(&page, regions, &table_rows, &enrich_out);
158 assemble::stamp_page_no(&mut nodes, i + 1);
159 doc.nodes.extend(nodes);
160 doc.links.extend(links);
161 }
162 if let Some((first, last)) = pages {
163 if first > total {
164 return Err(PdfError::Pdfium(format!(
165 "page range {first}-{last} is outside the document ({total} page(s))"
166 )));
167 }
168 }
169 assemble::merge_continuations(&mut doc.nodes);
170 Ok(doc)
171}
172
173/// Threads ONNX inference may use, capped by `DOCLING_RS_PDF_THREADS` if set.
174/// Defaults to the available parallelism (ort otherwise picks a low number).
175#[cfg(feature = "ml")]
176pub(crate) fn intra_threads() -> usize {
177 if let Some(n) = std::env::var("DOCLING_RS_PDF_THREADS")
178 .ok()
179 .and_then(|v| v.parse::<usize>().ok())
180 .filter(|&n| n > 0)
181 {
182 return n;
183 }
184 std::thread::available_parallelism()
185 .map(|n| n.get())
186 .unwrap_or(1)
187}
188
189#[cfg(feature = "ml")]
190/// True when `DOCLING_RS_FP32` (any value but `0`) forces the full-precision
191/// models even where an INT8 variant sits next to the fp32 default.
192pub(crate) fn fp32_forced() -> bool {
193 std::env::var("DOCLING_RS_FP32")
194 .map(|v| v != "0")
195 .unwrap_or(false)
196}
197
198#[cfg(feature = "ml")]
199/// Should the int8 model defaults be skipped in favor of fp32? Either the
200/// user said so (`DOCLING_RS_FP32`), or a GPU execution provider is selected
201/// (#74) — the int8 exports are QDQ graphs calibrated for CPU kernels and
202/// only conformance-validated there. An explicit `DOCLING_*_ONNX` path
203/// override still wins over this at every call site.
204pub(crate) fn prefer_fp32() -> bool {
205 fp32_forced() || ep::prefers_fp32()
206}
207
208#[cfg(feature = "ml")]
209/// Resolve a default (CWD-relative) asset path. If it doesn't exist relative
210/// to the current directory, try next to the executable and one level above
211/// it (following symlinks — the layout `scripts/install/install.sh` produces:
212/// `/usr/local/bin/docling-rs` → `/usr/local/docling.rs/bin/docling-rs`
213/// with `models/` and `.pdfium/` in `/usr/local/docling.rs`). Lets an
214/// installed binary run from any working directory with no env vars; explicit
215/// env overrides never reach this. Returns `rel` unchanged when nothing
216/// exists anywhere, so callers' error messages keep the familiar path.
217pub(crate) fn resolve_asset(rel: &str) -> String {
218 if std::path::Path::new(rel).exists() {
219 return rel.to_string();
220 }
221 if let Some(dir) = std::env::current_exe()
222 .ok()
223 .and_then(|p| p.canonicalize().ok())
224 .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
225 {
226 for base in [Some(dir.as_path()), dir.parent()].into_iter().flatten() {
227 let p = base.join(rel);
228 if p.exists() {
229 return p.to_string_lossy().into_owned();
230 }
231 }
232 }
233 rel.to_string()
234}
235
236/// One resolved runtime asset — which file a stage would load right now,
237/// given the CWD, the env overrides and the int8/fp32 preference.
238#[cfg(feature = "ml")]
239#[derive(Debug, Clone)]
240pub struct ModelEntry {
241 /// Pipeline stage, e.g. `layout`, `tableformer.decoder`, `ocr.rec`.
242 pub stage: &'static str,
243 /// The resolved path (absolute or CWD-relative, as it will be opened).
244 pub path: String,
245 /// Whether the file exists right now.
246 pub found: bool,
247 /// File size in bytes (0 when missing) — enough to tell an int8 quant
248 /// from an fp32 graph, or a stale model from a re-published one, at a
249 /// glance without hashing gigabytes per request.
250 pub bytes: u64,
251}
252
253/// Resolve the whole runtime model set **without loading anything** — the
254/// exact selection each stage performs at load time (layout honors the
255/// int8/fp32 preference, TableFormer its decoder ranking, OCR the language
256/// pair), plus the pdfium library. docling-serve exposes this at
257/// `/v1/config` and logs it at startup, so "the server picked up different
258/// models" is one `curl` away instead of a mystery of dissolved tables.
259/// Resolution is CWD-relative with an exe-dir fallback, so the answer can
260/// legitimately differ between two working directories.
261#[cfg(feature = "ml")]
262pub fn model_inventory() -> Vec<ModelEntry> {
263 fn entry(stage: &'static str, path: String) -> ModelEntry {
264 let meta = std::fs::metadata(&path).ok();
265 ModelEntry {
266 stage,
267 found: meta.is_some(),
268 bytes: meta.map(|m| m.len()).unwrap_or(0),
269 path,
270 }
271 }
272 let (enc, dec, bbx) = tableformer::resolved_paths();
273 let (rec, dict) = ocr::resolve_rec_pair(ocr::OcrLang::from_env());
274 let pdfium =
275 std::env::var("PDFIUM_DYNAMIC_LIB_PATH").unwrap_or_else(|_| resolve_asset(".pdfium/lib"));
276 vec![
277 entry(
278 "layout",
279 model_path(
280 "DOCLING_LAYOUT_ONNX",
281 "models/layout_heron.onnx",
282 "models/layout_heron_int8.onnx",
283 ),
284 ),
285 entry("tableformer.encoder", enc),
286 entry("tableformer.decoder", dec),
287 entry("tableformer.bbox", bbx),
288 entry("ocr.rec", rec),
289 entry("ocr.dict", dict),
290 entry("pdfium", pdfium),
291 ]
292}
293
294/// Resolve a model path: an explicit env override always wins; otherwise the
295/// INT8 variant of the default path when it exists on disk (the quantized
296/// models are conformance-validated — see docs/PDF_CONFORMANCE.md — and load/run
297/// markedly faster on CPU), unless `DOCLING_RS_FP32` opts back into full
298/// precision; else the fp32 default.
299#[cfg(feature = "ml")]
300pub(crate) fn model_path(env: &str, fp32_default: &str, int8_default: &str) -> String {
301 if let Ok(p) = std::env::var(env) {
302 return p;
303 }
304 if !prefer_fp32() {
305 let p = resolve_asset(int8_default);
306 if std::path::Path::new(&p).exists() {
307 return p;
308 }
309 }
310 resolve_asset(fp32_default)
311}
312
313/// Decode a standalone image with hard resource limits. A crafted image can
314/// declare enormous dimensions in a few-KB file; `image::load_from_memory`
315/// then tries to allocate the full pixel buffer (e.g. 60000×60000 → ~10 GB),
316/// and allocation failure aborts the whole process, bypassing the per-request
317/// panic catch. The 256 MiB alloc / 30000-px caps below turn that into a
318/// recoverable decode error instead. `DOCLING_RS_MAX_IMAGE_PIXELS` overrides
319/// the per-side pixel cap for the rare legitimately-huge scan.
320///
321/// Gated on `ml`: the only callers (`convert_image`, the METS backend) are
322/// ML-only, and the `image` crate is an `ml`-feature dependency — the
323/// text-layer wasm build has neither.
324#[cfg(feature = "ml")]
325pub(crate) fn decode_image_limited(bytes: &[u8]) -> Result<image::RgbImage, PdfError> {
326 let max_side: u32 = std::env::var("DOCLING_RS_MAX_IMAGE_PIXELS")
327 .ok()
328 .and_then(|v| v.parse().ok())
329 .unwrap_or(30_000);
330 decode_image_with_max_side(bytes, max_side)
331}
332
333#[cfg(feature = "ml")]
334fn decode_image_with_max_side(bytes: &[u8], max_side: u32) -> Result<image::RgbImage, PdfError> {
335 use image::ImageReader;
336 use std::io::Cursor;
337
338 let mut limits = image::Limits::default();
339 limits.max_image_width = Some(max_side);
340 limits.max_image_height = Some(max_side);
341 limits.max_alloc = Some(256 * 1024 * 1024);
342
343 let mut reader = ImageReader::new(Cursor::new(bytes))
344 .with_guessed_format()
345 .map_err(|e| PdfError::Pdfium(format!("image: {e}")))?;
346 reader.limits(limits);
347 Ok(reader
348 .decode()
349 .map_err(|e| PdfError::Pdfium(format!("image: {e}")))?
350 .into_rgb8())
351}
352
353#[cfg(feature = "ml")]
354/// One page's assembled output: typed nodes plus the page's hyperlinks, kept
355/// separate so pages processed out of order can be stitched back in page order.
356type PageOut = (Vec<Node>, Vec<(String, String)>);
357
358#[cfg(feature = "ml")]
359/// The pool-wide TableFormer slot: one instance shared by every worker, loaded
360/// lazily on the first table region any worker sees. Tables appear on a
361/// minority of pages, so per-worker copies mostly multiplied ~0.4 GB of
362/// weights+arenas by the pool size for nothing; a single shared instance keeps
363/// the peak flat regardless of pool width, and a table's structure prediction
364/// is independent of which worker runs it, so output is byte-identical. The
365/// mutex serialises concurrent tables — the shared instance is loaded with the
366/// full intra-op thread budget to compensate (one wide TableFormer instead of
367/// several narrow ones).
368enum TfSlot {
369 /// Not attempted yet (no table seen so far).
370 Unloaded,
371 /// Load attempted, graphs absent — geometric fallback (warned once).
372 Missing,
373 Ready(tableformer::TableFormer),
374}
375
376#[cfg(feature = "ml")]
377type SharedTables = Arc<Mutex<TfSlot>>;
378
379#[cfg(feature = "ml")]
380/// The same lazy shared-slot pattern for the (rarer still) enrichment models:
381/// one instance per pipeline, loaded on the first region that needs it.
382enum EnrichSlot<T> {
383 Unloaded,
384 /// Load attempted, model files absent — enrichment skipped (warned once).
385 Missing,
386 Ready(T),
387}
388
389#[cfg(feature = "ml")]
390type SharedClassifier = Arc<Mutex<EnrichSlot<enrich::PictureClassifier>>>;
391#[cfg(feature = "ml")]
392type SharedCodeFormula = Arc<Mutex<EnrichSlot<enrich::CodeFormula>>>;
393
394#[cfg(feature = "ml")]
395/// The opt-in enrichment passes, mirroring docling's `PdfPipelineOptions`
396/// flags (`do_picture_classification`, `do_code_enrichment`,
397/// `do_formula_enrichment`). All off by default.
398#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
399pub struct EnrichmentOptions {
400 /// Classify each picture with DocumentFigureClassifier (26 classes).
401 pub picture_classification: bool,
402 /// Rewrite code blocks (and detect their language) with CodeFormulaV2.
403 pub code: bool,
404 /// Decode display formulas to LaTeX with CodeFormulaV2.
405 pub formula: bool,
406}
407
408#[cfg(feature = "ml")]
409impl EnrichmentOptions {
410 fn any(&self) -> bool {
411 self.picture_classification || self.code || self.formula
412 }
413}
414
415#[cfg(feature = "ml")]
416/// A self-contained set of the per-page models (layout, OCR). Each parallel
417/// page-worker owns its own `Worker` so inference runs concurrently without
418/// sharing an ONNX session (`ort`'s `Session::run` is `&mut self`); only the
419/// rarely-hit TableFormer is shared (see [`TfSlot`]).
420struct Worker {
421 /// `None` when `no_ocr` skips layout entirely — no model load, no inference.
422 layout: Option<layout::LayoutModel>,
423 ocr: Option<ocr::OcrModel>,
424 /// Shared TableFormer slot; `None` when `no_table_former`/`no_ocr` skip it.
425 tables: Option<SharedTables>,
426 /// Shared enrichment slots; `None` unless the corresponding flag is on.
427 classifier: Option<SharedClassifier>,
428 code_formula: Option<SharedCodeFormula>,
429 enrich: EnrichmentOptions,
430 /// Skip layout, OCR, and TableFormer; reconstruct text purely from the PDF's
431 /// embedded text layer. See [`Pipeline::no_ocr`].
432 no_ocr: bool,
433 /// Discard the embedded text layer and OCR every page. See
434 /// [`Pipeline::force_full_page_ocr`].
435 force_full_page_ocr: bool,
436 /// Keep text-panel pictures as pictures instead of demoting them to
437 /// paragraphs. See [`Pipeline::no_text_panels`].
438 no_text_panels: bool,
439 /// Which recognition model [`Self::ocr`] loads. See [`Pipeline::ocr_lang`].
440 ocr_lang: ocr::OcrLang,
441}
442
443#[cfg(feature = "ml")]
444impl Worker {
445 #[allow(clippy::too_many_arguments)] // mirrors the Pipeline's option set
446 fn load(
447 intra: usize,
448 tables: Option<SharedTables>,
449 enrich_slots: (Option<SharedClassifier>, Option<SharedCodeFormula>),
450 enrich: EnrichmentOptions,
451 no_ocr: bool,
452 force_full_page_ocr: bool,
453 no_text_panels: bool,
454 ocr_lang: ocr::OcrLang,
455 ) -> Result<Self, PdfError> {
456 Ok(Self {
457 layout: if no_ocr {
458 None
459 } else {
460 Some(layout::LayoutModel::load_with(intra).map_err(PdfError::Layout)?)
461 },
462 ocr: None,
463 tables,
464 classifier: enrich_slots.0,
465 code_formula: enrich_slots.1,
466 enrich,
467 no_ocr,
468 force_full_page_ocr,
469 no_text_panels,
470 ocr_lang,
471 })
472 }
473
474 /// Run layout (+ OCR for cell-less pages) + TableFormer and assemble page `n`
475 /// into its nodes and links. Pure given the page (mutates only the worker's
476 /// lazily-loaded OCR model), so it is safe to run concurrently across pages.
477 fn process(&mut self, n: usize, page: &mut PdfPage) -> Result<PageOut, PdfError> {
478 if self.no_ocr {
479 // Fastest path: no layout/OCR/TableFormer inference at all. The PDF's
480 // embedded text cells (if any) become flat, line-grouped paragraphs in
481 // reading order via the same orphan-region machinery that normally
482 // rescues text the detector missed — here it rescues *all* of it.
483 // Pages with no embedded text layer (scanned/image-only) yield nothing;
484 // convert those without `no_ocr`.
485 let mut regions = Vec::new();
486 assemble::add_orphan_regions(&mut regions, &page.cells);
487 let table_rows = vec![None; regions.len()];
488 let enrich_out = vec![None; regions.len()];
489 return Ok(timing::timed("assemble_page", || {
490 assemble::assemble_page(page, regions, &table_rows, &enrich_out)
491 }));
492 }
493 let regions = timing::timed("layout.predict", || {
494 self.layout
495 .as_mut()
496 .expect("layout model loaded unless no_ocr")
497 .predict(&page.image, page.width, page.height)
498 })
499 .map_err(|e| PdfError::Layout(format!("page {}: {e}", n + 1)))?;
500 self.finish_page(n, page, regions)
501 }
502
503 /// Layout-detect a whole batch of pages with one inference call (issue #73),
504 /// then run each page's remaining stages (OCR / TableFormer / enrichment /
505 /// assembly) per page. Index-aligned with `items`; a layout failure fails
506 /// every page in the batch (they shared the one inference call).
507 fn process_batch(&mut self, items: &mut [(usize, PdfPage)]) -> Vec<Result<PageOut, PdfError>> {
508 if self.no_ocr {
509 // No layout model to batch — the text-layer-only path is per page.
510 return items
511 .iter_mut()
512 .map(|(n, page)| {
513 let n = *n;
514 self.process(n, page)
515 })
516 .collect();
517 }
518 let inputs: Vec<(&image::RgbImage, f32, f32)> = items
519 .iter()
520 .map(|(_, page)| (&page.image, page.width, page.height))
521 .collect();
522 let batched = timing::timed("layout.predict", || {
523 self.layout
524 .as_mut()
525 .expect("layout model loaded unless no_ocr")
526 .predict_batch(&inputs)
527 });
528 match batched {
529 Ok(all) => items
530 .iter_mut()
531 .zip(all)
532 .map(|((n, page), regions)| self.finish_page(*n, page, regions))
533 .collect(),
534 Err(e) => items
535 .iter()
536 .map(|(n, _)| Err(PdfError::Layout(format!("page {}: {e}", n + 1))))
537 .collect(),
538 }
539 }
540
541 /// Everything after layout detection: per-label confidence thresholds,
542 /// overlap resolution, orphan-text recovery, OCR for cell-less pages,
543 /// TableFormer, enrichment, and page assembly.
544 fn finish_page(
545 &mut self,
546 n: usize,
547 page: &mut PdfPage,
548 regions: Vec<layout::Region>,
549 ) -> Result<PageOut, PdfError> {
550 // Force-OCR is exactly "pretend the text layer is not there": clear
551 // every cell kind the extractors produced before anything reads them,
552 // and the ordinary no-text-layer machinery below — full-page OCR,
553 // OCR-fed TableFormer matching — takes over unchanged. (`no_ocr` wins
554 // when both are set, mirroring docling, where `force_full_page_ocr`
555 // is a sub-option of `do_ocr`; the no-ocr path never reaches here.)
556 // Done here rather than in `process` so the batched layout path
557 // (`process_batch` → `finish_page`) honors the flag too.
558 if self.force_full_page_ocr {
559 page.cells.clear();
560 page.code_cells.clear();
561 page.word_cells.clear();
562 }
563 // Quant-robustness guard: the default int8 layout graph keeps its
564 // confidences near the 0.5 label thresholds, and a different CPU's
565 // quantized kernels can flip a whole page's detections under them —
566 // tables and paragraphs then dissolve into orphan one-liners while the
567 // same build converts the page perfectly elsewhere. When a dense
568 // digital page ends up with detections covering almost none of its
569 // text cells, re-run that one page on the fp32 graph (lazy-loaded,
570 // auto-int8 selection only) and keep whichever detections cover more.
571 let mut regions = regions;
572 if !page.cells.is_empty() {
573 let thresholded = |rs: &[layout::Region]| -> Vec<layout::Region> {
574 rs.iter()
575 .filter(|r| r.score >= layout::label_threshold(r.label))
576 .cloned()
577 .collect()
578 };
579 let text_cells = page
580 .cells
581 .iter()
582 .filter(|c| !c.text.trim().is_empty())
583 .count();
584 let cov = assemble::layout_cell_coverage(&thresholded(®ions), &page.cells);
585 if text_cells >= 15 && cov < 0.5 {
586 let retry = self
587 .layout
588 .as_mut()
589 .expect("layout model loaded unless no_ocr")
590 .predict_fp32_fallback(&page.image, page.width, page.height)
591 .map_err(|e| PdfError::Layout(format!("page {}: {e}", n + 1)))?;
592 if let Some(retry) = retry {
593 let cov2 = assemble::layout_cell_coverage(&thresholded(&retry), &page.cells);
594 if cov2 > cov {
595 eprintln!(
596 "docling-pdf: page {}: int8 layout covered {:.0}% of the text \
597 cells; the fp32 retry covers {:.0}% — using it",
598 n + 1,
599 cov * 100.0,
600 cov2 * 100.0
601 );
602 regions = retry;
603 }
604 }
605 }
606 }
607 // docling's LayoutPostprocessor drops each detection below its label's
608 // confidence threshold (stricter than the 0.3 base the predictor keeps),
609 // before any overlap resolution. This removes the low-confidence tables /
610 // pictures / list-items that otherwise double-emit or mis-classify.
611 if std::env::var("DOCLING_RS_DEBUG_REGIONS").is_ok() {
612 for r in ®ions {
613 eprintln!(
614 "DBG raw {} {:.2} [{:.0},{:.0},{:.0},{:.0}]",
615 r.label, r.score, r.l, r.t, r.r, r.b
616 );
617 }
618 }
619 regions.retain(|r| r.score >= layout::label_threshold(r.label));
620 // Resolve overlapping detections once, before OCR.
621 let mut regions = assemble::resolve(regions);
622 // Emit text the detector missed as orphan text regions (docling parity).
623 assemble::add_orphan_regions(&mut regions, &page.cells);
624 // Drop phantom empty low-confidence picture boxes (docling parity).
625 assemble::drop_false_pictures(&mut regions, &page.cells, page.width, page.height);
626 // A regular region fully inside a surviving table/index/picture is that
627 // special's child (a cell / in-figure label), not a separate block —
628 // remove it so it isn't emitted twice (docling parity).
629 assemble::drop_contained_regulars(&mut regions);
630 // No text layer → recognise text from the page image via OCR.
631 let ocred = page.cells.is_empty();
632 if ocred {
633 if self.ocr.is_none() {
634 self.ocr = Some(ocr::OcrModel::load(self.ocr_lang).map_err(PdfError::Ocr)?);
635 }
636 let cells = timing::timed("ocr.page", || {
637 self.ocr
638 .as_mut()
639 .unwrap()
640 .ocr_page(&page.image, ®ions, page.scale)
641 })
642 .map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
643 page.cells = cells;
644 // Table interiors carry no words yet: region-scoped OCR skips
645 // table labels, and a scanned page has no pdfium text layer — so
646 // TableFormer's cell matcher got an empty word list and the table
647 // dissolved (#173). Recognize the table regions' word crops
648 // (mirroring the browser scanned path): `word_cells` feeds the
649 // matcher, and the same cells join `cells` so the geometric
650 // fallback and the table's region text see them too.
651 if regions.iter().any(|r| assemble::is_table_like(r.label)) {
652 let words = timing::timed("ocr.table_words", || {
653 self.ocr
654 .as_mut()
655 .unwrap()
656 .ocr_table_words(&page.image, ®ions, page.scale)
657 })
658 .map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
659 page.cells.extend(words.iter().cloned());
660 page.word_cells = words;
661 }
662 }
663 // Region-scoped OCR skips `picture` interiors, and a digital page's
664 // text layer cannot see into an embedded raster either — so a figure
665 // that is really a text box (terms-and-conditions exported as an
666 // image) lost its words on every page kind. Python docling OCRs the
667 // bitmap-covered areas of *every* page — even digital ones — once they
668 // exceed `bitmap_area_threshold` (5 % of the page); the browser paths
669 // already do. Recognize the big text-less crops here too; the panel
670 // demotion / orphan recovery below place the lines.
671 let mut pic_cells: Vec<pdfium_backend::TextCell> = Vec::new();
672 {
673 let page_area = (page.width * page.height).max(1.0);
674 let has_text = |r: &layout::Region| {
675 page.cells.iter().any(|c| {
676 let ca = ((c.r - c.l) * (c.b - c.t)).max(1.0);
677 let ix = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
678 let iy = (r.b.min(c.b) - r.t.max(c.t)).max(0.0);
679 !c.text.trim().is_empty() && ix * iy / ca > 0.5
680 })
681 };
682 // A captioned picture can never demote to a text panel (see
683 // recover_text_panels), and on digital pages its speculative OCR
684 // would be discarded anyway — don't pay for it.
685 let captioned = |r: &layout::Region| {
686 regions.iter().any(|c| {
687 c.label == "caption"
688 && c.r.min(r.r) - c.l.max(r.l) > 0.0
689 && ((c.t >= r.b && c.t - r.b <= 25.0) || (r.t >= c.b && r.t - c.b <= 25.0))
690 })
691 };
692 let bare: Vec<layout::Region> = regions
693 .iter()
694 .filter(|r| {
695 r.label == "picture"
696 && (r.r - r.l) * (r.b - r.t) / page_area >= 0.05
697 && !has_text(r)
698 && (ocred || !captioned(r))
699 })
700 .map(|r| layout::Region {
701 label: "text",
702 ..r.clone()
703 })
704 .collect();
705 if !bare.is_empty() {
706 if self.ocr.is_none() {
707 self.ocr = Some(ocr::OcrModel::load(self.ocr_lang).map_err(PdfError::Ocr)?);
708 }
709 pic_cells = timing::timed("ocr.pictures", || {
710 self.ocr
711 .as_mut()
712 .unwrap()
713 .ocr_page(&page.image, &bare, page.scale)
714 })
715 .map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
716 page.cells.extend(pic_cells.iter().cloned());
717 }
718 }
719 let cells_before_pic_ocr = page.cells.len() - pic_cells.len();
720 // A "picture" that is really a colored text panel — dense, wide,
721 // multi-line — reads out as paragraphs instead of shipping as pixels;
722 // sparse in-picture text (a chart's labels) keeps the crop and is
723 // emitted beside it via orphan recovery. `no_text_panels` (#173) opts
724 // out entirely for image-extraction workflows.
725 if !self.no_text_panels {
726 assemble::recover_text_panels(&mut regions, &page.cells);
727 }
728 // On an OCR'd page, in-picture text that did NOT demote its picture is
729 // still emitted beside the kept crop (matching the browser scanned
730 // path). On a digital page it is not: docling's groundtruth keeps
731 // photos silent even when our OCR reads noise off them
732 // (picture_classification stays byte-exact), so the recognized cells
733 // there only ever serve the panel-demotion decision above.
734 if ocred && !pic_cells.is_empty() {
735 // Pictures (and wrappers) no longer count as claimers (#165), so
736 // the plain orphan pass places the recognized lines directly.
737 assemble::add_orphan_regions(&mut regions, &pic_cells);
738 } else if !ocred && !pic_cells.is_empty() {
739 // Digital page, picture kept: its speculative OCR cells must not
740 // linger in the text-cell set (they were appended at the tail).
741 let kept: Vec<layout::Region> = regions
742 .iter()
743 .filter(|r| r.label == "picture")
744 .cloned()
745 .collect();
746 let tail = page.cells.split_off(cells_before_pic_ocr);
747 page.cells.extend(tail.into_iter().filter(|c| {
748 !kept.iter().any(|r| {
749 let ca = ((c.r - c.l) * (c.b - c.t)).max(1.0);
750 let ix = (r.r.min(c.r) - r.l.max(c.l)).max(0.0);
751 let iy = (r.b.min(c.b) - r.t.max(c.t)).max(0.0);
752 ix * iy / ca > 0.5
753 })
754 }));
755 }
756 // TableFormer structure per table region (else geometric fallback). The
757 // shared slot is only locked (and lazily loaded) when the page actually
758 // has a table, so table-free documents never pay for TableFormer at all.
759 let mut table_rows: Vec<Option<Vec<Vec<String>>>> = vec![None; regions.len()];
760 if let Some(slot) = self.tables.as_ref() {
761 if regions.iter().any(|r| assemble::is_table_like(r.label)) {
762 timing::timed("tableformer", || {
763 let mut guard = slot.lock().unwrap();
764 if matches!(*guard, TfSlot::Unloaded) {
765 // Full intra-op width: tables serialise on this mutex, so
766 // the one instance gets the whole thread budget.
767 *guard = match tableformer::TableFormer::load_with(intra_threads()) {
768 Some(tf) => TfSlot::Ready(tf),
769 None => TfSlot::Missing,
770 };
771 }
772 if let TfSlot::Ready(tf) = &mut *guard {
773 for (i, r) in regions.iter().enumerate() {
774 if assemble::is_table_like(r.label) {
775 table_rows[i] = tf.predict_table_rows(
776 &page.image,
777 [r.l, r.t, r.r, r.b],
778 &page.word_cells,
779 );
780 }
781 }
782 }
783 });
784 }
785 }
786 if std::env::var("DOCLING_RS_DEBUG_REGIONS").is_ok() {
787 for (i, r) in regions.iter().enumerate() {
788 eprintln!(
789 "DBG final {} {:.2} [{:.0},{:.0},{:.0},{:.0}] rows={:?}",
790 r.label,
791 r.score,
792 r.l,
793 r.t,
794 r.r,
795 r.b,
796 table_rows[i]
797 .as_ref()
798 .map(|t| (t.len(), t.first().map(|r| r.len())))
799 );
800 }
801 eprintln!(
802 "DBG cells={} words={}",
803 page.cells.len(),
804 page.word_cells.len()
805 );
806 }
807 // Enrichment passes (opt-in): DocumentPictureClassifier over picture
808 // regions, CodeFormulaV2 over code/formula regions. Same shared-slot
809 // shape as TableFormer — one lazily-loaded instance per pipeline, only
810 // ever locked when a page actually has a matching region.
811 let mut enrich_out: Vec<Option<assemble::Enrichment>> = vec![None; regions.len()];
812 if let Some(slot) = self.classifier.as_ref() {
813 if regions.iter().any(|r| r.label == "picture") {
814 timing::timed("picture_classifier", || {
815 let mut guard = slot.lock().unwrap();
816 if matches!(*guard, EnrichSlot::Unloaded) {
817 *guard = match enrich::PictureClassifier::load_with(intra_threads()) {
818 Some(m) => EnrichSlot::Ready(m),
819 None => EnrichSlot::Missing,
820 };
821 }
822 if let EnrichSlot::Ready(model) = &mut *guard {
823 for (i, r) in regions.iter().enumerate() {
824 if r.label != "picture" {
825 continue;
826 }
827 let Some(crop) = assemble::crop_region_scaled(
828 page,
829 [r.l, r.t, r.r, r.b],
830 enrich::CLASSIFIER_SCALE,
831 ) else {
832 continue;
833 };
834 match model.classify(&crop) {
835 Ok(classes) => {
836 enrich_out[i] =
837 Some(assemble::Enrichment::PictureClasses(classes));
838 }
839 Err(e) => eprintln!("docling-pdf: page {}: {e}", n + 1),
840 }
841 }
842 }
843 });
844 }
845 }
846 if let Some(slot) = self.code_formula.as_ref() {
847 let wants = |label: &str| {
848 (label == "code" && self.enrich.code) || (label == "formula" && self.enrich.formula)
849 };
850 if regions.iter().any(|r| wants(r.label)) {
851 timing::timed("code_formula", || {
852 let mut guard = slot.lock().unwrap();
853 if matches!(*guard, EnrichSlot::Unloaded) {
854 *guard = match enrich::CodeFormula::load_with(intra_threads()) {
855 Some(m) => EnrichSlot::Ready(m),
856 None => EnrichSlot::Missing,
857 };
858 }
859 if let EnrichSlot::Ready(model) = &mut *guard {
860 for (i, r) in regions.iter().enumerate() {
861 if !wants(r.label) {
862 continue;
863 }
864 // docling crops the postprocessed cluster box — the
865 // union of the region's text cells, not the raw
866 // detector box — expanded by 18% per side, at
867 // ~120 dpi.
868 let [bl, bt, br, bb] = assemble::region_cell_bbox(r, &page.cells)
869 .unwrap_or([r.l, r.t, r.r, r.b]);
870 let (w, h) = (br - bl, bb - bt);
871 let ex = enrich::CODE_FORMULA_EXPANSION;
872 let bbox = [bl - w * ex, bt - h * ex, br + w * ex, bb + h * ex];
873 let Some(crop) = assemble::crop_region_scaled(
874 page,
875 bbox,
876 enrich::CODE_FORMULA_SCALE,
877 ) else {
878 continue;
879 };
880 let kind = if r.label == "code" {
881 enrich::CodeFormulaKind::Code
882 } else {
883 enrich::CodeFormulaKind::Formula
884 };
885 match model.predict(&crop, kind) {
886 Ok(text) => {
887 enrich_out[i] = Some(match kind {
888 enrich::CodeFormulaKind::Code => {
889 let (code, language) =
890 enrich::extract_code_language(&text);
891 assemble::Enrichment::Code {
892 language,
893 text: code,
894 }
895 }
896 enrich::CodeFormulaKind::Formula => {
897 assemble::Enrichment::Formula { latex: text }
898 }
899 });
900 }
901 Err(e) => eprintln!("docling-pdf: page {}: {e}", n + 1),
902 }
903 }
904 }
905 });
906 }
907 }
908 Ok(timing::timed("assemble_page", || {
909 assemble::assemble_page(page, regions, &table_rows, &enrich_out)
910 }))
911 }
912}
913
914#[cfg(feature = "ml")]
915/// Per-worker ONNX intra-op threads. The layout model is memory-bandwidth bound,
916/// so on a typical machine two threads per worker (sharing one in-cache copy of
917/// the weights) extracts more throughput than one fat model or many single-thread
918/// workers. `DOCLING_RS_PDF_INTRA` overrides for per-machine tuning.
919fn pdf_intra() -> usize {
920 if let Some(n) = std::env::var("DOCLING_RS_PDF_INTRA")
921 .ok()
922 .and_then(|v| v.parse::<usize>().ok())
923 .filter(|&n| n > 0)
924 {
925 return n;
926 }
927 if intra_threads() >= 2 {
928 2
929 } else {
930 1
931 }
932}
933
934#[cfg(feature = "ml")]
935/// How many page-workers to spin up for a multi-page PDF. `DOCLING_RS_PDF_WORKERS`
936/// overrides; otherwise size the pool so `workers × intra ≈ cores`, capped at 4 so
937/// a worst-case pool holds a bounded amount of model memory (~0.4 GB per worker)
938/// and does not oversaturate the memory bus with model-weight traffic.
939fn pdf_worker_count() -> usize {
940 if let Some(n) = std::env::var("DOCLING_RS_PDF_WORKERS")
941 .ok()
942 .and_then(|v| v.parse::<usize>().ok())
943 .filter(|&n| n > 0)
944 {
945 return n;
946 }
947 (intra_threads() / pdf_intra()).clamp(1, 4)
948}
949
950#[cfg(feature = "ml")]
951/// Max pages a worker layout-detects with one batched inference call (issue
952/// #73). Workers drain the work channel opportunistically up to this size —
953/// whatever is already rendered gets batched, so batching never *waits* for
954/// pages and adds no latency when rendering is the bottleneck.
955///
956/// Default: 4 on 8+ cores, 1 (per-page) below. Measured on a 4-core box the
957/// batch only adds cache pressure and costs pipeline overlap (2 workers × 2
958/// threads: 8.1 s/conv at batch=1 vs 9.3 s at batch=4 on the 9-page
959/// 2206.01062 fixture); the single-session amortization it buys needs the
960/// wider thread budget of a many-core machine. Output is bit-identical at
961/// every batch size, so this is purely a throughput knob.
962/// `DOCLING_RS_PDF_LAYOUT_BATCH` overrides; `1` restores per-page inference.
963fn pdf_layout_batch() -> usize {
964 std::env::var("DOCLING_RS_PDF_LAYOUT_BATCH")
965 .ok()
966 .and_then(|v| v.parse::<usize>().ok())
967 .filter(|&n| n > 0)
968 .unwrap_or_else(|| if intra_threads() >= 8 { 4 } else { 1 })
969}
970
971#[cfg(feature = "ml")]
972/// Minimum page count before a PDF is worth the parallel worker pool. Below this,
973/// the serial primary (running its model on every core) is faster than fanning out
974/// — the helper pool's one-time model-load cost only pays off once enough pages
975/// share it. `DOCLING_RS_PDF_PARALLEL_MIN` overrides.
976fn pdf_parallel_min() -> usize {
977 std::env::var("DOCLING_RS_PDF_PARALLEL_MIN")
978 .ok()
979 .and_then(|v| v.parse::<usize>().ok())
980 .filter(|&n| n > 0)
981 .unwrap_or(6)
982}
983
984#[cfg(feature = "ml")]
985/// A reusable PDF pipeline. The **primary** worker runs its models on every core,
986/// so a single-page / small / image / METS input is converted at full intra-op
987/// speed with no pool to load. A document with enough pages instead fans out
988/// across a **pool** of narrower workers processed concurrently. Both load lazily
989/// and are cached for reuse, so a one-shot conversion only pays for what it uses.
990pub struct Pipeline {
991 /// Full-intra worker for the serial path; loaded on first serial use.
992 primary: Option<Worker>,
993 /// Narrower workers (≈cores/`target_workers` threads each) for the parallel
994 /// path; loaded on first multi-page use and cached.
995 pool: Vec<Worker>,
996 /// The single TableFormer instance every worker shares (see [`TfSlot`]).
997 tables: SharedTables,
998 /// The shared enrichment-model slots (same pattern as [`TfSlot`]).
999 classifier: SharedClassifier,
1000 code_formula: SharedCodeFormula,
1001 /// Desired pool size for multi-page documents.
1002 target_workers: usize,
1003 /// Page count at/above which the parallel pool is worth its load cost.
1004 parallel_min: usize,
1005 /// Skip loading/running TableFormer; table regions fall back to geometric
1006 /// reconstruction. See [`Pipeline::no_table_former`].
1007 no_table_former: bool,
1008 /// Skip layout, OCR, and TableFormer entirely. See [`Pipeline::no_ocr`].
1009 no_ocr: bool,
1010 /// OCR every page even when it carries a text layer. See
1011 /// [`Pipeline::force_full_page_ocr`].
1012 force_full_page_ocr: bool,
1013 /// Never demote text-panel pictures. See [`Pipeline::no_text_panels`].
1014 no_text_panels: bool,
1015 /// Opt-in enrichment passes. See [`Pipeline::enrichments`].
1016 enrich: EnrichmentOptions,
1017 /// 1-based inclusive page window to convert. See [`Pipeline::pages`].
1018 page_range: Option<(usize, usize)>,
1019 /// OCR recognition language. See [`Pipeline::ocr_lang`].
1020 ocr_lang: ocr::OcrLang,
1021}
1022
1023#[cfg(feature = "ml")]
1024impl Pipeline {
1025 /// Construct the pipeline. Models load lazily on first use (full-intra primary
1026 /// for serial inputs, the helper pool for multi-page PDFs), so nothing is
1027 /// loaded that a given document doesn't need.
1028 pub fn new() -> Result<Self, PdfError> {
1029 Ok(Self {
1030 primary: None,
1031 pool: Vec::new(),
1032 tables: Arc::new(Mutex::new(TfSlot::Unloaded)),
1033 classifier: Arc::new(Mutex::new(EnrichSlot::Unloaded)),
1034 code_formula: Arc::new(Mutex::new(EnrichSlot::Unloaded)),
1035 target_workers: pdf_worker_count(),
1036 parallel_min: pdf_parallel_min(),
1037 no_table_former: false,
1038 no_ocr: false,
1039 force_full_page_ocr: false,
1040 no_text_panels: false,
1041 enrich: EnrichmentOptions::default(),
1042 page_range: None,
1043 ocr_lang: ocr::OcrLang::from_env(),
1044 })
1045 }
1046
1047 /// Convert only pages `first..=last` (**1-based**, like the page numbers a
1048 /// PDF viewer shows — issue #80's `--pages A-B`). Out-of-range pages are
1049 /// skipped before rasterization, so the cost is proportional to the window,
1050 /// not the document. `last` past the end of the document clamps; a window
1051 /// that selects no pages at all (`first` beyond the last page) is an error
1052 /// at convert time. `None` (the default) converts everything.
1053 pub fn pages(mut self, range: Option<(usize, usize)>) -> Self {
1054 self.page_range = range;
1055 self
1056 }
1057
1058 /// In-place variant of [`pages`](Self::pages) for a long-lived pipeline
1059 /// (e.g. docling-serve's warm instance) that applies a per-request window
1060 /// without rebuilding — unlike the model switches, the window is pure
1061 /// configuration. Set it before every conversion; it stays until changed.
1062 pub fn set_pages(&mut self, range: Option<(usize, usize)>) {
1063 self.page_range = range;
1064 }
1065
1066 /// OCR recognition language (see [`OcrLang`]): English by default, `ch`
1067 /// for the multilingual docling-conformance model. `None` keeps the
1068 /// process default (`DOCLING_RS_OCR_LANG`, else English). Set before the
1069 /// first conversion; for a warm pipeline use
1070 /// [`set_ocr_lang`](Self::set_ocr_lang).
1071 pub fn ocr_lang(mut self, lang: Option<ocr::OcrLang>) -> Self {
1072 self.set_ocr_lang(lang);
1073 self
1074 }
1075
1076 /// In-place variant of [`ocr_lang`](Self::ocr_lang) for a long-lived
1077 /// pipeline (docling-serve's warm instance). Unlike the page window this
1078 /// is a *model* switch: any worker whose cached recognition model was
1079 /// loaded for a different language drops it, to be lazily reloaded on the
1080 /// next OCR-needing page (cheap — the rec models are ~10 MB).
1081 pub fn set_ocr_lang(&mut self, lang: Option<ocr::OcrLang>) {
1082 let lang = lang.unwrap_or_else(ocr::OcrLang::from_env);
1083 self.ocr_lang = lang;
1084 for worker in self.primary.iter_mut().chain(self.pool.iter_mut()) {
1085 if worker.ocr_lang != lang {
1086 worker.ocr_lang = lang;
1087 worker.ocr = None;
1088 }
1089 }
1090 }
1091
1092 /// Resolve the configured 1-based window against a page count into the
1093 /// 0-based inclusive form the backend walks, validating it selects at
1094 /// least one existing page.
1095 fn resolve_range(&self, total: usize) -> Result<Option<(usize, usize)>, PdfError> {
1096 let Some((first, last)) = self.page_range else {
1097 return Ok(None);
1098 };
1099 if first == 0 || last < first {
1100 return Err(PdfError::Pdfium(format!(
1101 "invalid page range {first}-{last} (pages are 1-based, first <= last)"
1102 )));
1103 }
1104 if first > total {
1105 return Err(PdfError::Pdfium(format!(
1106 "page range {first}-{last} is outside the document ({total} page(s))"
1107 )));
1108 }
1109 Ok(Some((first - 1, last.min(total) - 1)))
1110 }
1111
1112 /// Enable the opt-in enrichment passes (docling's
1113 /// `do_picture_classification` / `do_code_enrichment` /
1114 /// `do_formula_enrichment`). Each enabled pass lazily loads its model on
1115 /// the first matching region; a missing model warns once and is skipped.
1116 /// Set before the first conversion (no effect on already-loaded workers).
1117 pub fn enrichments(mut self, opts: EnrichmentOptions) -> Self {
1118 self.enrich = opts;
1119 self
1120 }
1121
1122 /// Skip loading and running the TableFormer table-structure model. Table
1123 /// regions still get emitted, but reconstructed geometrically from cell
1124 /// positions instead of via the ONNX model's predicted structure — faster
1125 /// (no model load, no per-table inference) at the cost of table fidelity.
1126 /// No effect if a worker is already loaded; set this before the first
1127 /// conversion.
1128 pub fn no_table_former(mut self, disable: bool) -> Self {
1129 self.no_table_former = disable;
1130 self
1131 }
1132
1133 /// Keep every detected `picture` region as a picture. By default an
1134 /// *uncaptioned* picture that reads like a dense, uniform text panel (a
1135 /// terms-and-conditions box exported as an image) is demoted into
1136 /// paragraphs (#157); a chart the layout mislabels can still trip that
1137 /// heuristic on scanned pages, and image-extraction workflows may simply
1138 /// want every crop — this flag disables the demotion entirely (#173).
1139 /// No effect on already-loaded workers; set before the first conversion.
1140 pub fn no_text_panels(mut self, disable: bool) -> Self {
1141 self.no_text_panels = disable;
1142 self
1143 }
1144
1145 /// Skip layout detection, OCR, and TableFormer entirely — no model load, no
1146 /// inference of any kind. The PDF's embedded text cells are grouped by line
1147 /// and emitted as plain paragraphs in reading order: no headings, lists,
1148 /// tables, code blocks, or pictures, since that structure comes from the
1149 /// layout model. The fastest possible PDF path, but pages with no embedded
1150 /// text layer (scanned/image-only PDFs) yield no text at all — convert those
1151 /// without this flag. Implies `no_table_former`. No effect if a worker is
1152 /// already loaded; set this before the first conversion.
1153 pub fn no_ocr(mut self, disable: bool) -> Self {
1154 self.no_ocr = disable;
1155 self
1156 }
1157
1158 /// OCR every page from its rendered image even when the page carries an
1159 /// embedded text layer — docling's `force_full_page_ocr`. The escape hatch
1160 /// for text layers that exist but lie: broken encodings, subset fonts with
1161 /// garbage mappings, a scanned form with a few typed-in fields. Ignored
1162 /// when [`no_ocr`](Self::no_ocr) is set, mirroring docling (there
1163 /// `force_full_page_ocr` is a sub-option of `do_ocr`).
1164 pub fn force_full_page_ocr(mut self, force: bool) -> Self {
1165 self.force_full_page_ocr = force;
1166 self
1167 }
1168
1169 /// The shared TableFormer slot handed to each worker, or `None` when the
1170 /// pipeline options skip TableFormer entirely.
1171 fn tables_slot(&self) -> Option<SharedTables> {
1172 if self.no_table_former || self.no_ocr {
1173 None
1174 } else {
1175 Some(Arc::clone(&self.tables))
1176 }
1177 }
1178
1179 /// The shared enrichment slots for a worker (`None` per model unless its
1180 /// flag is on; `no_ocr` skips layout, so there are no regions to enrich).
1181 fn enrich_slots(&self) -> (Option<SharedClassifier>, Option<SharedCodeFormula>) {
1182 if self.no_ocr || !self.enrich.any() {
1183 return (None, None);
1184 }
1185 (
1186 self.enrich
1187 .picture_classification
1188 .then(|| Arc::clone(&self.classifier)),
1189 (self.enrich.code || self.enrich.formula).then(|| Arc::clone(&self.code_formula)),
1190 )
1191 }
1192
1193 /// Eagerly load the models (the full-intra serial worker: layout + OCR, and
1194 /// the shared TableFormer unless disabled) so the first conversion doesn't pay
1195 /// the load cost. Idempotent; respects `no_ocr` / `no_table_former` (with
1196 /// `no_ocr` there is nothing to load). The docling.rs analogue of docling's
1197 /// `DocumentConverter.initialize_pipeline`.
1198 pub fn warm_up(&mut self) -> Result<(), PdfError> {
1199 self.primary()?;
1200 Ok(())
1201 }
1202
1203 /// The full-intra serial worker, loaded on first use.
1204 fn primary(&mut self) -> Result<&mut Worker, PdfError> {
1205 if self.primary.is_none() {
1206 self.primary = Some(Worker::load(
1207 intra_threads(),
1208 self.tables_slot(),
1209 self.enrich_slots(),
1210 self.enrich,
1211 self.no_ocr,
1212 self.force_full_page_ocr,
1213 self.no_text_panels,
1214 self.ocr_lang,
1215 )?);
1216 }
1217 Ok(self.primary.as_mut().unwrap())
1218 }
1219
1220 /// Convert a PDF (bytes) to a [`DoclingDocument`]. A document with fewer than
1221 /// `parallel_min` pages (or a pool size of 1) streams through the full-intra
1222 /// primary; a larger one renders on this thread (pdfium is not thread-safe) and
1223 /// fans the pages out across the worker pool, reassembled in page order so the
1224 /// output is byte-identical to the serial path.
1225 pub fn convert(
1226 &mut self,
1227 bytes: &[u8],
1228 password: Option<&str>,
1229 name: &str,
1230 ) -> Result<DoclingDocument, PdfError> {
1231 let pages = pdfium_backend::page_count(bytes, password)?;
1232 let range = self.resolve_range(pages)?;
1233 // Serial vs parallel is decided by the pages actually converted: a
1234 // 3-page window over a 500-page PDF should not pay the pool load.
1235 let selected = range.map_or(pages, |(a, b)| b - a + 1);
1236 let doc = if self.target_workers >= 2 && selected >= self.parallel_min {
1237 self.convert_parallel(bytes, password, name, range)?
1238 } else {
1239 self.convert_serial(bytes, password, name, range)?
1240 };
1241 timing::report();
1242 Ok(doc)
1243 }
1244
1245 /// Stream pages one at a time through the primary worker — render → process →
1246 /// drop — so the document holds ~one page bitmap (~5 MB) at a time.
1247 fn convert_serial(
1248 &mut self,
1249 bytes: &[u8],
1250 password: Option<&str>,
1251 name: &str,
1252 range: Option<(usize, usize)>,
1253 ) -> Result<DoclingDocument, PdfError> {
1254 let mut doc = DoclingDocument::new(name);
1255 let render_image = !self.no_ocr;
1256 let worker = self.primary()?;
1257 pdfium_backend::for_each_page(
1258 bytes,
1259 password,
1260 render_image,
1261 range,
1262 |n, _total, mut page| {
1263 let (mut nodes, links) = worker.process(n, &mut page)?;
1264 assemble::stamp_page_no(&mut nodes, n + 1);
1265 doc.nodes.extend(nodes);
1266 doc.links.extend(links);
1267 Ok::<(), PdfError>(())
1268 },
1269 )?;
1270 assemble::merge_continuations(&mut doc.nodes);
1271 Ok(doc)
1272 }
1273
1274 /// Render pages serially on this thread (pdfium) and process them in parallel
1275 /// across the worker pool. A bounded channel applies backpressure so only a
1276 /// handful of page bitmaps are resident at once; results carry their page
1277 /// index and are reassembled in order, so the output is byte-identical to the
1278 /// serial path.
1279 fn convert_parallel(
1280 &mut self,
1281 bytes: &[u8],
1282 password: Option<&str>,
1283 name: &str,
1284 range: Option<(usize, usize)>,
1285 ) -> Result<DoclingDocument, PdfError> {
1286 self.ensure_pool()?;
1287 let n_workers = self.pool.len();
1288 let render_image = !self.no_ocr;
1289 let layout_batch = pdf_layout_batch();
1290 // Bound sized so every worker can accumulate a full layout batch while
1291 // rendering stays ahead (and never below the pre-#73 render-ahead of
1292 // two pages per worker); still a hard cap on resident page bitmaps.
1293 let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * layout_batch.max(2));
1294 let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
1295 let results: Arc<Mutex<Vec<(usize, PageOut)>>> = Arc::new(Mutex::new(Vec::new()));
1296 let first_err: Arc<Mutex<Option<PdfError>>> = Arc::new(Mutex::new(None));
1297
1298 // Move the pool into the scope so each worker gets an exclusive `&mut`.
1299 let mut workers = std::mem::take(&mut self.pool);
1300 std::thread::scope(|s| {
1301 for worker in workers.iter_mut() {
1302 let work_rx = Arc::clone(&work_rx);
1303 let results = Arc::clone(&results);
1304 let first_err = Arc::clone(&first_err);
1305 s.spawn(move || loop {
1306 // Hold the receiver lock only for the recv (plus a non-blocking
1307 // drain up to the layout batch size); release before the (long)
1308 // per-page work so other workers can pull concurrently.
1309 let mut batch = Vec::new();
1310 {
1311 let rx = work_rx.lock().unwrap();
1312 match rx.recv() {
1313 Ok(item) => {
1314 batch.push(item);
1315 while batch.len() < layout_batch {
1316 match rx.try_recv() {
1317 Ok(item) => batch.push(item),
1318 Err(_) => break,
1319 }
1320 }
1321 }
1322 Err(_) => break,
1323 }
1324 }
1325 let outs = worker.process_batch(&mut batch);
1326 for ((idx, _), out) in batch.iter().zip(outs) {
1327 match out {
1328 Ok(out) => results.lock().unwrap().push((*idx, out)),
1329 Err(e) => {
1330 let mut slot = first_err.lock().unwrap();
1331 if slot.is_none() {
1332 *slot = Some(e);
1333 }
1334 }
1335 }
1336 }
1337 });
1338 }
1339 // Render on this thread and feed the workers; backpressure blocks here
1340 // when the channel is full. Dropping `work_tx` afterwards signals the
1341 // workers (recv → Err) to finish.
1342 let render = pdfium_backend::for_each_page(
1343 bytes,
1344 password,
1345 render_image,
1346 range,
1347 |i, _total, page| {
1348 work_tx
1349 .send((i, page))
1350 .map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
1351 },
1352 );
1353 drop(work_tx);
1354 if let Err(e) = render {
1355 let mut slot = first_err.lock().unwrap();
1356 if slot.is_none() {
1357 *slot = Some(e);
1358 }
1359 }
1360 });
1361 // Threads have joined; restore the pool for the next conversion.
1362 self.pool = workers;
1363
1364 if let Some(e) = first_err.lock().unwrap().take() {
1365 return Err(e);
1366 }
1367 let mut results = Arc::try_unwrap(results)
1368 .unwrap_or_else(|arc| Mutex::new(arc.lock().unwrap().clone()))
1369 .into_inner()
1370 .unwrap();
1371 results.sort_by_key(|(idx, _)| *idx);
1372 let mut doc = DoclingDocument::new(name);
1373 for (idx, (mut nodes, links)) in results {
1374 assemble::stamp_page_no(&mut nodes, idx + 1);
1375 doc.nodes.extend(nodes);
1376 doc.links.extend(links);
1377 }
1378 assemble::merge_continuations(&mut doc.nodes);
1379 Ok(doc)
1380 }
1381
1382 /// Convert a PDF in **streaming** mode: `emit` is called with each finalized,
1383 /// in-document-order batch of nodes (and that span's recovered links) as pages
1384 /// complete, so a caller can serialize Markdown page by page instead of waiting
1385 /// for the whole document. The batches are exactly the buffered [`convert`]'s
1386 /// nodes, split at safe block boundaries by [`assemble::StreamAssembler`] — the
1387 /// parallel path reorders pages back into document order before emitting, so
1388 /// the output is identical regardless of worker scheduling.
1389 ///
1390 /// `emit` runs on the calling thread (never a worker), so it needn't be `Send`
1391 /// and its backpressure throttles the whole pipeline. Returning `Err` from
1392 /// `emit` aborts the conversion with that error.
1393 pub fn convert_streaming<F>(
1394 &mut self,
1395 bytes: &[u8],
1396 password: Option<&str>,
1397 name: &str,
1398 emit: F,
1399 ) -> Result<(), PdfError>
1400 where
1401 F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
1402 {
1403 let _ = name; // page nodes carry no name; the caller owns the document name.
1404 let pages = pdfium_backend::page_count(bytes, password)?;
1405 let range = self.resolve_range(pages)?;
1406 let selected = range.map_or(pages, |(a, b)| b - a + 1);
1407 let r = if self.target_workers >= 2 && selected >= self.parallel_min {
1408 self.convert_streaming_parallel(bytes, password, range, emit)
1409 } else {
1410 self.convert_streaming_serial(bytes, password, range, emit)
1411 };
1412 timing::report();
1413 r
1414 }
1415
1416 /// Serial streaming: render → process → emit, one page at a time, holding back
1417 /// only the tail that might still merge into the next page.
1418 fn convert_streaming_serial<F>(
1419 &mut self,
1420 bytes: &[u8],
1421 password: Option<&str>,
1422 range: Option<(usize, usize)>,
1423 mut emit: F,
1424 ) -> Result<(), PdfError>
1425 where
1426 F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
1427 {
1428 let mut asm = assemble::StreamAssembler::new();
1429 let render_image = !self.no_ocr;
1430 let worker = self.primary()?;
1431 pdfium_backend::for_each_page(
1432 bytes,
1433 password,
1434 render_image,
1435 range,
1436 |n, _total, mut page| {
1437 let (nodes, links) = worker.process(n, &mut page)?;
1438 emit(asm.push(nodes), links)
1439 },
1440 )?;
1441 emit(asm.finish(), Vec::new())
1442 }
1443
1444 /// Parallel streaming: pages render serially on a dedicated thread (pdfium is
1445 /// not thread-safe) and process across the worker pool; results carry their
1446 /// page index and are reordered on the calling thread into a
1447 /// [`assemble::StreamAssembler`], which emits each page in document order as
1448 /// soon as its predecessors have arrived. Bounded channels keep only a handful
1449 /// of pages resident and let `emit`'s backpressure reach the renderer.
1450 fn convert_streaming_parallel<F>(
1451 &mut self,
1452 bytes: &[u8],
1453 password: Option<&str>,
1454 range: Option<(usize, usize)>,
1455 mut emit: F,
1456 ) -> Result<(), PdfError>
1457 where
1458 F: FnMut(Vec<Node>, Vec<(String, String)>) -> Result<(), PdfError>,
1459 {
1460 self.ensure_pool()?;
1461 let n_workers = self.pool.len();
1462 let render_image = !self.no_ocr;
1463 let layout_batch = pdf_layout_batch();
1464 // Bound sized so every worker can accumulate a full layout batch while
1465 // rendering stays ahead (and never below the pre-#73 render-ahead of
1466 // two pages per worker); still a hard cap on resident page bitmaps.
1467 let (work_tx, work_rx) = sync_channel::<(usize, PdfPage)>(n_workers * layout_batch.max(2));
1468 let work_rx: Arc<Mutex<Receiver<(usize, PdfPage)>>> = Arc::new(Mutex::new(work_rx));
1469 // Workers and the renderer report here; the calling thread drains it in
1470 // page order. Bounded so workers block (bounding resident bitmaps) when the
1471 // consumer falls behind.
1472 let (res_tx, res_rx) = sync_channel::<Result<(usize, PageOut), PdfError>>(n_workers * 2);
1473
1474 let mut workers = std::mem::take(&mut self.pool);
1475 let mut asm = assemble::StreamAssembler::new();
1476 let mut first_err: Option<PdfError> = None;
1477
1478 std::thread::scope(|s| {
1479 // Workers: pull a batch of pages (whatever is already rendered, up
1480 // to the layout batch size), process it, report (index-tagged)
1481 // results.
1482 for worker in workers.iter_mut() {
1483 let work_rx = Arc::clone(&work_rx);
1484 let res_tx = res_tx.clone();
1485 s.spawn(move || 'outer: loop {
1486 let mut batch = Vec::new();
1487 {
1488 let rx = work_rx.lock().unwrap();
1489 match rx.recv() {
1490 Ok(item) => {
1491 batch.push(item);
1492 while batch.len() < layout_batch {
1493 match rx.try_recv() {
1494 Ok(item) => batch.push(item),
1495 Err(_) => break,
1496 }
1497 }
1498 }
1499 Err(_) => break,
1500 }
1501 }
1502 let outs = worker.process_batch(&mut batch);
1503 for ((idx, _), out) in batch.iter().zip(outs) {
1504 if res_tx.send(out.map(|o| (*idx, o))).is_err() {
1505 break 'outer; // consumer gone
1506 }
1507 }
1508 });
1509 }
1510 // Renderer: feed pages to the pool on its own thread (pdfium stays on a
1511 // single thread); report a render error through the same channel.
1512 {
1513 let res_tx = res_tx.clone();
1514 s.spawn(move || {
1515 let render = pdfium_backend::for_each_page(
1516 bytes,
1517 password,
1518 render_image,
1519 range,
1520 |i, _total, page| {
1521 work_tx
1522 .send((i, page))
1523 .map_err(|_| PdfError::Pdfium("page-worker channel closed".into()))
1524 },
1525 );
1526 drop(work_tx); // signal workers to finish
1527 if let Err(e) = render {
1528 let _ = res_tx.send(Err(e));
1529 }
1530 });
1531 }
1532 // Drop our own sender so the channel closes once the threads finish.
1533 drop(res_tx);
1534
1535 // Collector (this thread): reorder into document order and emit.
1536 // With a page window, indices start at the window's first page.
1537 let mut buffer: BTreeMap<usize, PageOut> = BTreeMap::new();
1538 let mut next = range.map_or(0, |(first, _)| first);
1539 for msg in res_rx.iter() {
1540 match msg {
1541 Err(e) => {
1542 if first_err.is_none() {
1543 first_err = Some(e);
1544 }
1545 }
1546 Ok((idx, out)) => {
1547 buffer.insert(idx, out);
1548 if first_err.is_some() {
1549 continue; // keep draining so the threads can exit
1550 }
1551 while let Some((nodes, links)) = buffer.remove(&next) {
1552 if let Err(e) = emit(asm.push(nodes), links) {
1553 first_err = Some(e);
1554 break;
1555 }
1556 next += 1;
1557 }
1558 }
1559 }
1560 }
1561 });
1562 // Threads have joined; restore the pool for the next conversion.
1563 self.pool = workers;
1564
1565 if let Some(e) = first_err {
1566 return Err(e);
1567 }
1568 emit(asm.finish(), Vec::new())
1569 }
1570
1571 /// Lazily grow the pool to `target_workers`, loading the new workers
1572 /// concurrently (model load is mostly I/O + mmap, so N loads overlap to roughly
1573 /// one load's wall-time). Cached for reuse across documents.
1574 fn ensure_pool(&mut self) -> Result<(), PdfError> {
1575 let need = self.target_workers.saturating_sub(self.pool.len());
1576 if need == 0 {
1577 return Ok(());
1578 }
1579 let intra = pdf_intra();
1580 let no_ocr = self.no_ocr;
1581 let force = self.force_full_page_ocr;
1582 let ntp = self.no_text_panels;
1583 let ocr_lang = self.ocr_lang;
1584 let enrich = self.enrich;
1585 let tables = self.tables_slot();
1586 let enrich_slots = self.enrich_slots();
1587 let loaded: Vec<Result<Worker, PdfError>> = std::thread::scope(|s| {
1588 let handles: Vec<_> = (0..need)
1589 .map(|_| {
1590 let tables = tables.clone();
1591 let enrich_slots = enrich_slots.clone();
1592 s.spawn(move || {
1593 Worker::load(
1594 intra,
1595 tables,
1596 enrich_slots,
1597 enrich,
1598 no_ocr,
1599 force,
1600 ntp,
1601 ocr_lang,
1602 )
1603 })
1604 })
1605 .collect();
1606 handles.into_iter().map(|h| h.join().unwrap()).collect()
1607 });
1608 for w in loaded {
1609 self.pool.push(w?);
1610 }
1611 Ok(())
1612 }
1613
1614 /// Convert a standalone image (PNG/JPEG/TIFF/WebP/…) as a single page —
1615 /// docling routes images through the same layout+OCR pipeline as a PDF page.
1616 pub fn convert_image(&mut self, bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
1617 let image = decode_image_limited(bytes)?;
1618 let (w, h) = image.dimensions();
1619 // The image is its own page rendered at 1 px per "point" (scale 1.0); a
1620 // standalone image has no text layer, so OCR supplies the cells.
1621 let page = PdfPage {
1622 width: w as f32,
1623 height: h as f32,
1624 scale: 1.0,
1625 cells: Vec::new(),
1626 code_cells: Vec::new(),
1627 word_cells: Vec::new(),
1628 image,
1629 links: Vec::new(),
1630 };
1631 self.process_pages(vec![page], name)
1632 }
1633
1634 /// Run layout (+ OCR for cell-less pages) and assemble each already-rendered
1635 /// page (image / METS inputs, which are small and already materialised).
1636 fn process_pages(
1637 &mut self,
1638 mut pages: Vec<PdfPage>,
1639 name: &str,
1640 ) -> Result<DoclingDocument, PdfError> {
1641 let mut doc = DoclingDocument::new(name);
1642 let worker = self.primary()?;
1643 for (n, page) in pages.iter_mut().enumerate() {
1644 let (mut nodes, links) = worker.process(n, page)?;
1645 assemble::stamp_page_no(&mut nodes, n + 1);
1646 doc.nodes.extend(nodes);
1647 doc.links.extend(links);
1648 }
1649 assemble::merge_continuations(&mut doc.nodes);
1650 Ok(doc)
1651 }
1652}
1653
1654#[cfg(feature = "ml")]
1655/// Convenience one-shot conversion (loads the pipeline per call). Errors are
1656/// detailed and surfaced (never silently skipped).
1657pub fn convert(
1658 bytes: &[u8],
1659 password: Option<&str>,
1660 name: &str,
1661) -> Result<DoclingDocument, PdfError> {
1662 convert_with_options(
1663 bytes,
1664 password,
1665 name,
1666 false,
1667 false,
1668 false,
1669 false,
1670 EnrichmentOptions::default(),
1671 None,
1672 None,
1673 )
1674}
1675
1676#[cfg(feature = "ml")]
1677/// Like [`convert`], but optionally skips loading/running TableFormer (see
1678/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
1679/// [`Pipeline::no_ocr`]), and/or enables the enrichment passes (see
1680/// [`Pipeline::enrichments`]).
1681// One positional per pipeline switch mirrors the Pipeline builder; growing
1682// past clippy's arity cap is the price of keeping this one-shot signature
1683// stable-ish instead of churning callers into an options struct mid-series.
1684#[allow(clippy::too_many_arguments)]
1685pub fn convert_with_options(
1686 bytes: &[u8],
1687 password: Option<&str>,
1688 name: &str,
1689 no_table_former: bool,
1690 no_ocr: bool,
1691 force_full_page_ocr: bool,
1692 no_text_panels: bool,
1693 enrich: EnrichmentOptions,
1694 pages: Option<(usize, usize)>,
1695 ocr_lang: Option<OcrLang>,
1696) -> Result<DoclingDocument, PdfError> {
1697 Pipeline::new()?
1698 .no_table_former(no_table_former)
1699 .no_ocr(no_ocr)
1700 .force_full_page_ocr(force_full_page_ocr)
1701 .no_text_panels(no_text_panels)
1702 .enrichments(enrich)
1703 .pages(pages)
1704 .ocr_lang(ocr_lang)
1705 .convert(bytes, password, name)
1706}
1707
1708#[cfg(feature = "ml")]
1709/// Convenience one-shot image conversion (loads the pipeline per call).
1710pub fn convert_image(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
1711 convert_image_with_options(
1712 bytes,
1713 name,
1714 false,
1715 false,
1716 false,
1717 EnrichmentOptions::default(),
1718 None,
1719 )
1720}
1721
1722#[cfg(feature = "ml")]
1723/// Like [`convert_image`], but optionally skips loading/running TableFormer (see
1724/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
1725/// [`Pipeline::no_ocr`]), and/or enables the enrichment passes.
1726pub fn convert_image_with_options(
1727 bytes: &[u8],
1728 name: &str,
1729 no_table_former: bool,
1730 no_ocr: bool,
1731 no_text_panels: bool,
1732 enrich: EnrichmentOptions,
1733 ocr_lang: Option<OcrLang>,
1734) -> Result<DoclingDocument, PdfError> {
1735 Pipeline::new()?
1736 .no_table_former(no_table_former)
1737 .no_ocr(no_ocr)
1738 .no_text_panels(no_text_panels)
1739 .enrichments(enrich)
1740 .ocr_lang(ocr_lang)
1741 .convert_image(bytes, name)
1742}
1743
1744#[cfg(feature = "ml")]
1745/// Convert pre-segmented pages (image + already-known text cells, e.g. METS/hOCR
1746/// scans) through the shared layout + assembly pipeline.
1747pub fn convert_pages(pages: Vec<PdfPage>, name: &str) -> Result<DoclingDocument, PdfError> {
1748 convert_pages_with_options(
1749 pages,
1750 name,
1751 false,
1752 false,
1753 false,
1754 EnrichmentOptions::default(),
1755 )
1756}
1757
1758#[cfg(feature = "ml")]
1759/// Like [`convert_pages`], but optionally skips loading/running TableFormer (see
1760/// [`Pipeline::no_table_former`]) and/or layout+OCR+TableFormer entirely (see
1761/// [`Pipeline::no_ocr`]), and/or enables the enrichment passes.
1762pub fn convert_pages_with_options(
1763 pages: Vec<PdfPage>,
1764 name: &str,
1765 no_table_former: bool,
1766 no_ocr: bool,
1767 no_text_panels: bool,
1768 enrich: EnrichmentOptions,
1769) -> Result<DoclingDocument, PdfError> {
1770 Pipeline::new()?
1771 .no_table_former(no_table_former)
1772 .no_text_panels(no_text_panels)
1773 .no_ocr(no_ocr)
1774 .enrichments(enrich)
1775 .process_pages(pages, name)
1776}
1777
1778#[cfg(feature = "ml")]
1779#[cfg(all(test, feature = "ml"))]
1780mod image_limit_tests {
1781 use super::decode_image_with_max_side;
1782
1783 /// A small valid PNG encoded via the `image` crate (robust vs. a hand-rolled
1784 /// byte literal).
1785 fn png_bytes(w: u32, h: u32) -> Vec<u8> {
1786 use std::io::Cursor;
1787 let img = image::RgbImage::new(w, h);
1788 let mut out = Vec::new();
1789 img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
1790 .unwrap();
1791 out
1792 }
1793
1794 #[test]
1795 fn normal_image_decodes_under_the_cap() {
1796 let img = decode_image_with_max_side(&png_bytes(8, 8), 30_000).expect("8x8 decodes");
1797 assert_eq!(img.dimensions(), (8, 8));
1798 }
1799
1800 #[test]
1801 fn dimensions_over_the_cap_are_rejected_not_aborted() {
1802 // A per-side cap below the image's declared size must yield a
1803 // recoverable Err, never an allocation-abort — the mechanism that stops
1804 // a crafted image declaring 60000×60000 from OOM-killing the process.
1805 let r = decode_image_with_max_side(&png_bytes(8, 8), 4);
1806 assert!(
1807 r.is_err(),
1808 "decode must fail under the pixel cap, not abort"
1809 );
1810 }
1811}
1812
1813#[cfg(test)]
1814mod median_tests {
1815 #[test]
1816 fn median_of_empty_is_zero_not_a_panic() {
1817 // A crafted table can leave a row/column with zero matched cells; the
1818 // even-count branch would index values[0 - 1] and panic (→ remote crash
1819 // via docling-serve) without the empty guard.
1820 assert_eq!(super::tf_match::median_for_test(&mut []), 0.0);
1821 assert_eq!(super::tf_match::median_for_test(&mut [4.0, 2.0]), 3.0);
1822 assert_eq!(super::tf_match::median_for_test(&mut [5.0, 1.0, 3.0]), 3.0);
1823 }
1824}
1825
1826#[cfg(test)]
1827mod send_check {
1828 /// The Node bindings (`docling-node`) run a shared [`super::Pipeline`] on
1829 /// libuv worker threads (`Arc<Mutex<Pipeline>>`), which is only sound while
1830 /// `Pipeline: Send` holds — this fails to compile if a non-`Send` field
1831 /// (e.g. an `Rc` or a raw pdfium handle) ever lands in the pipeline.
1832 fn assert_send<T: Send>() {}
1833
1834 #[test]
1835 fn pipeline_is_send() {
1836 assert_send::<super::Pipeline>();
1837 }
1838}