Skip to main content

docling_pdf/
ocr.rs

1//! OCR for scanned pages, via the PP-OCRv3 recognition model (CRNN/SVTR) run
2//! with `ort`. The layout model already locates text regions on the page image
3//! (it works without a text layer), so OCR only needs *recognition*: each text
4//! region is cropped, split into lines by horizontal projection, and each line
5//! is recognised and decoded with CTC — producing [`TextCell`]s the normal
6//! layout assembly then consumes. This avoids a separate text-detection model.
7
8use image::RgbImage;
9use ort::session::Session;
10use ort::value::Tensor;
11
12use crate::layout::Region;
13// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
14// so the wasm build shares it verbatim (#79 phase 2).
15use crate::ocr_prep::{
16    batch_input, decode_row_scored, dict_chars, prep_region_lines, prep_table_words, width_batches,
17    PrepLine, REC_HEIGHT,
18};
19use crate::pdfium_backend::TextCell;
20
21pub struct OcrModel {
22    rec: Session,
23    /// CTC classes: index 0 = blank, 1..=6623 = dictionary, 6624 = space.
24    chars: Vec<String>,
25}
26
27/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs.
28///
29/// The default is **English** (`.models/ocr_rec_en.onnx` + `.models/en_dict.txt`):
30/// the multilingual `ch_` model reads Latin scripts with badly degraded word
31/// spacing (glued words on ordinary English scans), which is the common
32/// real-world case. `Ch` selects the `ch_` pair (`.models/ocr_rec.onnx` +
33/// `.models/ppocr_keys_v1.txt`) — that is what upstream docling conformance is
34/// measured with, and `scripts/conformance/pdf_*.sh` pin it explicitly (by
35/// path, which wins over this selector).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum OcrLang {
38    /// en_PP-OCRv3 — English-only, proper Latin word spacing.
39    #[default]
40    En,
41    /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
42    Ch,
43}
44
45impl OcrLang {
46    /// Parse a user-supplied language id. `None` for anything but `en`/`ch`
47    /// (trimmed, case-insensitive) — callers surface their own error/warning.
48    pub fn parse(s: &str) -> Option<Self> {
49        match s.trim().to_ascii_lowercase().as_str() {
50            "en" => Some(Self::En),
51            "ch" => Some(Self::Ch),
52            _ => None,
53        }
54    }
55
56    /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
57    /// English default; unknown values warn and use English).
58    pub fn from_env() -> Self {
59        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_LANG") else {
60            return Self::default();
61        };
62        Self::parse(&raw).unwrap_or_else(|| {
63            eprintln!("docling-pdf: DOCLING_RS_OCR_LANG={raw:?} is not en|ch; using en");
64            Self::default()
65        })
66    }
67}
68
69/// Which document regions feed the OCR — docling 2.116's `OcrMode` (#254,
70/// upstream docling#3710). Upstream restructured its pipeline so OCR runs
71/// *after* layout, on layout regions filtered by the PDF text layer — the
72/// architecture this port has always had — and named the strategies:
73///
74/// - `PdfAwareLayoutRegions` (upstream's **default**): OCR only layout regions
75///   the embedded text layer can't cover. Exactly the standard path here —
76///   scanned pages OCR their regions, digital pages OCR only text-less bitmap
77///   areas.
78/// - `FullPage` / `LayoutRegions`: ignore the PDF text layer and OCR
79///   everything. Both map onto the [`force_full_page_ocr`] machinery (discard
80///   the text layer, OCR every layout region): the upstream distinction —
81///   whole-page vs per-region *detector* input — has no analogue in this
82///   engine, whose PP-OCR recognizer always consumes per-region line crops.
83///
84/// [`force_full_page_ocr`]: crate::Pipeline::force_full_page_ocr
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum OcrMode {
87    /// Upstream's `default`: currently wired to `PdfAwareLayoutRegions`.
88    #[default]
89    Default,
90    /// OCR the full page, text layer ignored (docling's `full_page`; the
91    /// mode-shaped spelling of `force_full_page_ocr`).
92    FullPage,
93    /// OCR every layout region, text layer ignored (docling's
94    /// `layout_regions`).
95    LayoutRegions,
96    /// OCR layout regions the text layer can't cover (docling's
97    /// `pdf_aware_layout_regions` — the default behavior).
98    PdfAwareLayoutRegions,
99}
100
101impl OcrMode {
102    /// Parse docling's mode ids. `None` for anything else — callers surface
103    /// their own error/warning.
104    pub fn parse(s: &str) -> Option<Self> {
105        match s.trim().to_ascii_lowercase().as_str() {
106            "default" => Some(Self::Default),
107            "full_page" => Some(Self::FullPage),
108            "layout_regions" => Some(Self::LayoutRegions),
109            "pdf_aware_layout_regions" => Some(Self::PdfAwareLayoutRegions),
110            _ => None,
111        }
112    }
113
114    /// The process-level choice from `DOCLING_RS_OCR_MODE` (empty/unset → the
115    /// default; unknown values warn and use the default).
116    pub fn from_env() -> Self {
117        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_MODE") else {
118            return Self::default();
119        };
120        Self::parse(&raw).unwrap_or_else(|| {
121            eprintln!(
122                "docling-pdf: DOCLING_RS_OCR_MODE={raw:?} is not \
123                 default|full_page|layout_regions|pdf_aware_layout_regions; using default"
124            );
125            Self::default()
126        })
127    }
128
129    /// Whether this mode discards the embedded text layer — the engine truth
130    /// both non-default modes reduce to.
131    pub fn forces_full_page(self) -> bool {
132        matches!(self, Self::FullPage | Self::LayoutRegions)
133    }
134}
135
136/// The process-level OCR render scale from `DOCLING_RS_OCR_SCALE` (#254,
137/// upstream docling#3877's `OcrOptions.scale`): pixels per PDF point fed to
138/// the recognizer. Unset/empty → `None` (OCR reads the pipeline's own page
139/// render, 2.0 px/pt); non-positive or unparsable values warn and are ignored.
140pub fn scale_from_env() -> Option<f32> {
141    let raw = docling_core::env::nonempty("DOCLING_RS_OCR_SCALE")?;
142    match raw.parse::<f32>() {
143        Ok(s) if s > 0.0 && s.is_finite() => Some(s),
144        _ => {
145            eprintln!(
146                "docling-pdf: DOCLING_RS_OCR_SCALE={raw:?} is not a positive number; ignored"
147            );
148            None
149        }
150    }
151}
152
153/// Resolve the recognition model + dictionary pair for `lang`. An English
154/// default that isn't on disk (older model checkouts) degrades to the `ch_`
155/// pair with a warning rather than failing — the usual missing-optional-asset
156/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
157/// over all of this; they are a pair, so set both together.
158pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
159    const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
160    const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
161    let want_ch = lang == OcrLang::Ch;
162    let pick = if want_ch { CH } else { EN };
163    let (mut rec, mut dict) = (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1));
164    if !want_ch && (!std::path::Path::new(&rec).exists() || !std::path::Path::new(&dict).exists()) {
165        let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
166        if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
167            eprintln!(
168                "docling-pdf: English OCR model not found ({rec}); falling back to the \
169                 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
170                 scripts/install/download_dependencies.sh"
171            );
172            (rec, dict) = (ch_rec, ch_dict);
173        }
174    }
175    (
176        docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
177        docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
178    )
179}
180
181impl OcrModel {
182    /// Load the recognition model and its character dictionary for `lang` —
183    /// see [`resolve_rec_pair`] for the selection rules (explicit
184    /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win).
185    pub fn load(lang: OcrLang) -> Result<Self, String> {
186        let (rec_path, dict_path) = resolve_rec_pair(lang);
187        // Single-threaded: ORT's multi-threaded float-reduction order varies
188        // across runs, which flips the CTC argmax on low-confidence characters
189        // (e.g. noisy faxes) and makes the snapshot output non-deterministic. The
190        // recognition inputs are tiny per-line crops, so the throughput cost is
191        // negligible.
192        let builder = Session::builder()
193            .map_err(|e| format!("ocr: builder: {e}"))?
194            .with_intra_threads(1)
195            .map_err(|e| format!("ocr: intra_threads: {e}"))?;
196        let rec = crate::ep::apply(builder)
197            .map_err(|e| format!("ocr: {e}"))?
198            .commit_from_file(&rec_path)
199            .map_err(|e| format!("ocr: load {rec_path}: {e}"))?;
200        let dict = std::fs::read_to_string(&dict_path)
201            .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
202        Ok(Self {
203            rec,
204            chars: dict_chars(&dict),
205        })
206    }
207
208    /// Recognise a batch of prepared *same-width* lines in one session run.
209    ///
210    /// Only equal widths ever share a run: same-width batching is
211    /// bit-identical to one-at-a-time recognition (each sample keeps its own
212    /// data and per-sample kernel reduction order — verified empirically on
213    /// the scanned corpus), whereas width-padding leaks into the real
214    /// timesteps through the model's global-attention blocks and measurably
215    /// changes low-confidence characters.
216    fn recognize_batch(
217        &mut self,
218        w: usize,
219        chunk: &[usize],
220        lines: &[PrepLine],
221    ) -> Result<Vec<(String, f32)>, String> {
222        let n = chunk.len();
223        let data = batch_input(w, chunk, lines);
224        let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
225            .map_err(|e| format!("ocr: input tensor: {e}"))?;
226        let outputs = self
227            .rec
228            .run(ort::inputs!["x" => input])
229            .map_err(|e| format!("ocr: rec inference: {e}"))?;
230        let (shape, probs) = outputs[0]
231            .try_extract_tensor::<f32>()
232            .map_err(|e| format!("ocr: extract rec: {e}"))?;
233        let t_len = shape[1] as usize;
234        let nc = shape[2] as usize;
235        Ok((0..n)
236            .map(|i| {
237                decode_row_scored(
238                    &self.chars,
239                    &probs[i * t_len * nc..(i + 1) * t_len * nc],
240                    nc,
241                )
242            })
243            .collect())
244    }
245
246    /// Recognize `lines` and reduce to orientation-probe evidence: the
247    /// confidence-weighted character count `Σ(conf × chars)` plus the raw
248    /// character total (#225). Same deterministic width-batching as page OCR.
249    pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
250        let mut weighted = 0.0f32;
251        let mut chars = 0usize;
252        for (w, chunk) in width_batches(lines) {
253            for (text, conf) in self.recognize_batch(w, &chunk, lines)? {
254                let n = text.trim().chars().count();
255                weighted += conf * n as f32;
256                chars += n;
257            }
258        }
259        Ok((weighted, chars))
260    }
261
262    /// OCR a page: produce text cells (page points) for every line found inside
263    /// the text regions, each paired with its recognition confidence (mean
264    /// emitted-character probability — feeds the page `ocr_score`, #183).
265    /// `scale` is image-px per page-point.
266    pub fn ocr_page(
267        &mut self,
268        img: &RgbImage,
269        regions: &[Region],
270        scale: f32,
271    ) -> Result<Vec<(TextCell, f32)>, String> {
272        // Gather every line crop on the page first (shared with the browser
273        // path), so equal-width lines can share a recognition run regardless
274        // of which region they came from.
275        let (bboxes, lines) = prep_region_lines(img, regions, scale);
276
277        // Deterministic width-batching (shared with the wasm path).
278        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
279        for (w, chunk) in width_batches(&lines) {
280            for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
281                texts[i] = text;
282            }
283        }
284
285        // Emit cells in page order, exactly as the sequential walk did.
286        let mut cells = Vec::new();
287        for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
288            let text = text.trim().to_string();
289            if text.is_empty() {
290                continue;
291            }
292            cells.push((TextCell { text, l, t, r, b }, conf));
293        }
294        Ok(cells)
295    }
296
297    /// Recognize the *word* crops inside the page's table regions (mirroring
298    /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
299    /// skips table labels, so a scanned table would otherwise reach the cell
300    /// matcher with no words at all and dissolve (#173). Returns word-level
301    /// [`TextCell`]s in page points.
302    pub fn ocr_table_words(
303        &mut self,
304        img: &RgbImage,
305        regions: &[Region],
306        scale: f32,
307    ) -> Result<Vec<(TextCell, f32)>, String> {
308        let (bboxes, lines) = prep_table_words(img, regions, scale);
309        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
310        for (w, chunk) in width_batches(&lines) {
311            for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
312                texts[i] = text;
313            }
314        }
315        let mut cells = Vec::new();
316        for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
317            let text = text.trim().to_string();
318            if text.is_empty() {
319                continue;
320            }
321            cells.push((TextCell { text, l, t, r, b }, conf));
322        }
323        Ok(cells)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// #254: docling's four `OcrMode` ids parse; `full_page`/`layout_regions`
332    /// reduce to the force-full-page machinery, the default/pdf-aware pair to
333    /// the standard text-layer-aware path. Unknown ids parse to nothing.
334    #[test]
335    fn ocr_mode_ids_parse_and_map_to_forcing() {
336        for (id, mode, forces) in [
337            ("default", OcrMode::Default, false),
338            ("full_page", OcrMode::FullPage, true),
339            ("layout_regions", OcrMode::LayoutRegions, true),
340            (
341                "pdf_aware_layout_regions",
342                OcrMode::PdfAwareLayoutRegions,
343                false,
344            ),
345        ] {
346            assert_eq!(OcrMode::parse(id), Some(mode));
347            assert_eq!(mode.forces_full_page(), forces, "{id}");
348        }
349        assert_eq!(OcrMode::parse(" Full_Page "), Some(OcrMode::FullPage));
350        assert_eq!(OcrMode::parse("easyocr"), None);
351        assert_eq!(OcrMode::parse(""), None);
352    }
353}