Skip to main content

fleischwolf_pdf/
lib.rs

1//! PDF backend for fleischwolf.
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
12mod assemble;
13mod dp_lines;
14pub mod layout;
15mod mets;
16mod ocr;
17pub mod pdfium_backend;
18pub mod resample;
19pub mod tableformer;
20
21use std::fmt;
22
23use fleischwolf_core::DoclingDocument;
24
25pub use mets::convert_mets_gbs;
26pub use pdfium_backend::{PdfDocument, PdfPage, TextCell};
27
28/// Errors from the PDF backend. Detailed and surfaced (never silently skipped).
29#[derive(Debug)]
30pub enum PdfError {
31    /// pdfium failed to bind, open, or read the document.
32    Pdfium(String),
33    /// The layout ONNX model failed to load or run.
34    Layout(String),
35    /// The OCR ONNX model failed to load or run.
36    Ocr(String),
37}
38
39impl fmt::Display for PdfError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            PdfError::Pdfium(m) => write!(f, "pdf: pdfium error: {m}"),
43            PdfError::Layout(m) => write!(f, "pdf: {m}"),
44            PdfError::Ocr(m) => write!(f, "pdf: {m}"),
45        }
46    }
47}
48
49impl std::error::Error for PdfError {}
50
51impl From<pdfium_render::prelude::PdfiumError> for PdfError {
52    fn from(e: pdfium_render::prelude::PdfiumError) -> Self {
53        PdfError::Pdfium(e.to_string())
54    }
55}
56
57/// Threads ONNX inference may use, capped by `FLEISCHWOLF_PDF_THREADS` if set.
58/// Defaults to the available parallelism (ort otherwise picks a low number).
59pub(crate) fn intra_threads() -> usize {
60    if let Some(n) = std::env::var("FLEISCHWOLF_PDF_THREADS")
61        .ok()
62        .and_then(|v| v.parse::<usize>().ok())
63        .filter(|&n| n > 0)
64    {
65        return n;
66    }
67    std::thread::available_parallelism()
68        .map(|n| n.get())
69        .unwrap_or(1)
70}
71
72/// A reusable PDF pipeline: the layout model is loaded once and reused across
73/// documents; OCR loads lazily the first time a scanned page is seen.
74pub struct Pipeline {
75    layout: layout::LayoutModel,
76    ocr: Option<ocr::OcrModel>,
77    /// TableFormer structure model; `None` when its ONNX graphs aren't present
78    /// (the assembler then falls back to geometric table reconstruction).
79    tables: Option<tableformer::TableFormer>,
80}
81
82impl Pipeline {
83    /// Load the layout model (the only always-required model). TableFormer loads
84    /// if its exported graphs are present, else table regions use the geometric
85    /// fallback.
86    pub fn new() -> Result<Self, PdfError> {
87        Ok(Self {
88            layout: layout::LayoutModel::load().map_err(PdfError::Layout)?,
89            ocr: None,
90            tables: tableformer::TableFormer::load(),
91        })
92    }
93
94    /// Convert a PDF (bytes) to a [`DoclingDocument`] via the discriminative
95    /// pipeline: pdfium text cells (or OCR for scanned pages) + per-page layout
96    /// detection, assembled in reading order. Errors are detailed and surfaced.
97    pub fn convert(
98        &mut self,
99        bytes: &[u8],
100        password: Option<&str>,
101        name: &str,
102    ) -> Result<DoclingDocument, PdfError> {
103        // Stream pages: render → process → drop one at a time, so a large PDF
104        // holds ~one page bitmap (~5 MB) rather than every page at once (which
105        // is gigabytes for a multi-thousand-page document and drives the machine
106        // into swap).
107        let mut doc = DoclingDocument::new(name);
108        pdfium_backend::for_each_page(bytes, password, |n, _total, mut page| {
109            self.process_one_page(n, &mut page, &mut doc)
110        })?;
111        assemble::merge_continuations(&mut doc.nodes);
112        Ok(doc)
113    }
114
115    /// Convert a standalone image (PNG/JPEG/TIFF/WebP/…) as a single page —
116    /// docling routes images through the same layout+OCR pipeline as a PDF page.
117    pub fn convert_image(&mut self, bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
118        let image = image::load_from_memory(bytes)
119            .map_err(|e| PdfError::Pdfium(format!("image: {e}")))?
120            .into_rgb8();
121        let (w, h) = image.dimensions();
122        // The image is its own page rendered at 1 px per "point" (scale 1.0); a
123        // standalone image has no text layer, so OCR supplies the cells.
124        let page = PdfPage {
125            width: w as f32,
126            height: h as f32,
127            scale: 1.0,
128            cells: Vec::new(),
129            code_cells: Vec::new(),
130            word_cells: Vec::new(),
131            image,
132        };
133        self.process_pages(vec![page], name)
134    }
135
136    /// Run layout (+ OCR for cell-less pages) and assemble one page into `doc`.
137    fn process_one_page(
138        &mut self,
139        n: usize,
140        page: &mut PdfPage,
141        doc: &mut DoclingDocument,
142    ) -> Result<(), PdfError> {
143        let regions = self
144            .layout
145            .predict(&page.image, page.width, page.height)
146            .map_err(|e| PdfError::Layout(format!("page {}: {e}", n + 1)))?;
147        // Resolve overlapping detections once, before OCR.
148        let regions = assemble::resolve(regions);
149        // No text layer → recognise text from the page image via OCR.
150        if page.cells.is_empty() {
151            if self.ocr.is_none() {
152                self.ocr = Some(ocr::OcrModel::load().map_err(PdfError::Ocr)?);
153            }
154            let cells = self
155                .ocr
156                .as_mut()
157                .unwrap()
158                .ocr_page(&page.image, &regions, page.scale)
159                .map_err(|e| PdfError::Ocr(format!("page {}: {e}", n + 1)))?;
160            page.cells = cells;
161        }
162        // TableFormer structure per table region (else geometric fallback).
163        let mut table_rows: Vec<Option<Vec<Vec<String>>>> = vec![None; regions.len()];
164        if let Some(tf) = self.tables.as_mut() {
165            for (i, r) in regions.iter().enumerate() {
166                if r.label == "table" {
167                    table_rows[i] = tf.predict_table_rows(
168                        &page.image,
169                        page.height,
170                        [r.l, r.t, r.r, r.b],
171                        &page.word_cells,
172                    );
173                }
174            }
175        }
176        assemble::assemble_page(page, regions, &table_rows, doc);
177        Ok(())
178    }
179
180    /// Run layout (+ OCR for cell-less pages) and assemble each already-rendered
181    /// page (image / METS inputs, which are small and already materialised).
182    fn process_pages(
183        &mut self,
184        mut pages: Vec<PdfPage>,
185        name: &str,
186    ) -> Result<DoclingDocument, PdfError> {
187        let mut doc = DoclingDocument::new(name);
188        for (n, page) in pages.iter_mut().enumerate() {
189            self.process_one_page(n, page, &mut doc)?;
190        }
191        assemble::merge_continuations(&mut doc.nodes);
192        Ok(doc)
193    }
194}
195
196/// Convenience one-shot conversion (loads the pipeline per call). Errors are
197/// detailed and surfaced (never silently skipped).
198pub fn convert(
199    bytes: &[u8],
200    password: Option<&str>,
201    name: &str,
202) -> Result<DoclingDocument, PdfError> {
203    Pipeline::new()?.convert(bytes, password, name)
204}
205
206/// Convenience one-shot image conversion (loads the pipeline per call).
207pub fn convert_image(bytes: &[u8], name: &str) -> Result<DoclingDocument, PdfError> {
208    Pipeline::new()?.convert_image(bytes, name)
209}
210
211/// Convert pre-segmented pages (image + already-known text cells, e.g. METS/hOCR
212/// scans) through the shared layout + assembly pipeline.
213pub fn convert_pages(pages: Vec<PdfPage>, name: &str) -> Result<DoclingDocument, PdfError> {
214    Pipeline::new()?.process_pages(pages, name)
215}