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    /// Single-threaded recognition sessions, one per parallel lane (see
23    /// [`Self::load_with`]); lines are dealt across them by batch index.
24    recs: Vec<Session>,
25    /// CTC classes: index 0 = blank, 1..=6623 = dictionary, 6624 = space.
26    chars: Vec<String>,
27}
28
29/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs.
30///
31/// The default is **English** (`.models/ocr_rec_en.onnx` + `.models/en_dict.txt`):
32/// the multilingual `ch_` model reads Latin scripts with badly degraded word
33/// spacing (glued words on ordinary English scans), which is the common
34/// real-world case. `Ch` selects the `ch_` pair (`.models/ocr_rec.onnx` +
35/// `.models/ppocr_keys_v1.txt`) — that is what upstream docling conformance is
36/// measured with, and `scripts/conformance/pdf_*.sh` pin it explicitly (by
37/// path, which wins over this selector).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum OcrLang {
40    /// en_PP-OCRv3 — English-only, proper Latin word spacing.
41    #[default]
42    En,
43    /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
44    Ch,
45}
46
47impl OcrLang {
48    /// Parse a user-supplied language id. `None` for anything but `en`/`ch`
49    /// (trimmed, case-insensitive) — callers surface their own error/warning.
50    pub fn parse(s: &str) -> Option<Self> {
51        match s.trim().to_ascii_lowercase().as_str() {
52            "en" => Some(Self::En),
53            "ch" => Some(Self::Ch),
54            _ => None,
55        }
56    }
57
58    /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
59    /// English default; unknown values warn and use English).
60    pub fn from_env() -> Self {
61        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_LANG") else {
62            return Self::default();
63        };
64        Self::parse(&raw).unwrap_or_else(|| {
65            eprintln!("docling-pdf: DOCLING_RS_OCR_LANG={raw:?} is not en|ch; using en");
66            Self::default()
67        })
68    }
69}
70
71/// Which document regions feed the OCR — docling 2.116's `OcrMode` (#254,
72/// upstream docling#3710). Upstream restructured its pipeline so OCR runs
73/// *after* layout, on layout regions filtered by the PDF text layer — the
74/// architecture this port has always had — and named the strategies:
75///
76/// - `PdfAwareLayoutRegions` (upstream's **default**): OCR only layout regions
77///   the embedded text layer can't cover. Exactly the standard path here —
78///   scanned pages OCR their regions, digital pages OCR only text-less bitmap
79///   areas.
80/// - `FullPage` / `LayoutRegions`: ignore the PDF text layer and OCR
81///   everything. Both map onto the [`force_full_page_ocr`] machinery (discard
82///   the text layer, OCR every layout region): the upstream distinction —
83///   whole-page vs per-region *detector* input — has no analogue in this
84///   engine, whose PP-OCR recognizer always consumes per-region line crops.
85///
86/// [`force_full_page_ocr`]: crate::Pipeline::force_full_page_ocr
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum OcrMode {
89    /// Upstream's `default`: currently wired to `PdfAwareLayoutRegions`.
90    #[default]
91    Default,
92    /// OCR the full page, text layer ignored (docling's `full_page`; the
93    /// mode-shaped spelling of `force_full_page_ocr`).
94    FullPage,
95    /// OCR every layout region, text layer ignored (docling's
96    /// `layout_regions`).
97    LayoutRegions,
98    /// OCR layout regions the text layer can't cover (docling's
99    /// `pdf_aware_layout_regions` — the default behavior).
100    PdfAwareLayoutRegions,
101}
102
103impl OcrMode {
104    /// Parse docling's mode ids. `None` for anything else — callers surface
105    /// their own error/warning.
106    pub fn parse(s: &str) -> Option<Self> {
107        match s.trim().to_ascii_lowercase().as_str() {
108            "default" => Some(Self::Default),
109            "full_page" => Some(Self::FullPage),
110            "layout_regions" => Some(Self::LayoutRegions),
111            "pdf_aware_layout_regions" => Some(Self::PdfAwareLayoutRegions),
112            _ => None,
113        }
114    }
115
116    /// The process-level choice from `DOCLING_RS_OCR_MODE` (empty/unset → the
117    /// default; unknown values warn and use the default).
118    pub fn from_env() -> Self {
119        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_MODE") else {
120            return Self::default();
121        };
122        Self::parse(&raw).unwrap_or_else(|| {
123            eprintln!(
124                "docling-pdf: DOCLING_RS_OCR_MODE={raw:?} is not \
125                 default|full_page|layout_regions|pdf_aware_layout_regions; using default"
126            );
127            Self::default()
128        })
129    }
130
131    /// Whether this mode discards the embedded text layer — the engine truth
132    /// both non-default modes reduce to.
133    pub fn forces_full_page(self) -> bool {
134        matches!(self, Self::FullPage | Self::LayoutRegions)
135    }
136}
137
138/// The process-level OCR render scale from `DOCLING_RS_OCR_SCALE` (#254,
139/// upstream docling#3877's `OcrOptions.scale`): pixels per PDF point fed to
140/// the recognizer. Unset/empty → `None` (OCR reads the pipeline's own page
141/// render, 2.0 px/pt); non-positive or unparsable values warn and are ignored.
142pub fn scale_from_env() -> Option<f32> {
143    let raw = docling_core::env::nonempty("DOCLING_RS_OCR_SCALE")?;
144    match raw.parse::<f32>() {
145        Ok(s) if s > 0.0 && s.is_finite() => Some(s),
146        _ => {
147            eprintln!(
148                "docling-pdf: DOCLING_RS_OCR_SCALE={raw:?} is not a positive number; ignored"
149            );
150            None
151        }
152    }
153}
154
155/// Resolve the recognition model + dictionary pair for `lang`. An English
156/// default that isn't on disk (older model checkouts) degrades to the `ch_`
157/// pair with a warning rather than failing — the usual missing-optional-asset
158/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
159/// over all of this; they are a pair, so set both together.
160pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
161    const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
162    const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
163    let want_ch = lang == OcrLang::Ch;
164    let pick = if want_ch { CH } else { EN };
165    let (mut rec, mut dict) = (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1));
166    if !want_ch && (!std::path::Path::new(&rec).exists() || !std::path::Path::new(&dict).exists()) {
167        let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
168        if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
169            eprintln!(
170                "docling-pdf: English OCR model not found ({rec}); falling back to the \
171                 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
172                 scripts/install/download_dependencies.sh"
173            );
174            (rec, dict) = (ch_rec, ch_dict);
175        }
176    }
177    (
178        docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
179        docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
180    )
181}
182
183/// One recognised line: its text and mean emitted-character confidence.
184type Recognized = (String, f32);
185
186impl OcrModel {
187    /// Load the recognition model and its character dictionary for `lang` —
188    /// see [`resolve_rec_pair`] for the selection rules (explicit
189    /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win) — with `lanes`
190    /// recognition sessions.
191    ///
192    /// Each session is pinned to one intra-op thread: ORT's multi-threaded
193    /// float-reduction order varies across runs, which flips the CTC argmax on
194    /// low-confidence characters (e.g. noisy faxes) and makes the snapshot
195    /// output non-deterministic. Recognition is linear in line width (~0.17 ms
196    /// per pixel column on one core) and on a scanned page it, plus the
197    /// orientation probe that reads the six widest lines, is ~35% of the wall
198    /// time while the other cores idle. Lines are independent, so `lanes`
199    /// sessions recognise disjoint same-width batches concurrently — each
200    /// line still sees exactly the single-thread kernel path, results are
201    /// placed by index, and the output is byte-identical to one lane.
202    /// `DOCLING_RS_OCR_SESSIONS` overrides the caller's lane count.
203    pub fn load_with(lang: OcrLang, lanes: usize) -> Result<Self, String> {
204        let (rec_path, dict_path) = resolve_rec_pair(lang);
205        let lanes = docling_core::env::parse::<usize>("DOCLING_RS_OCR_SESSIONS")
206            .filter(|&n| n > 0)
207            .unwrap_or(lanes)
208            .clamp(1, 8);
209        let open = || -> Result<Session, String> {
210            let builder = Session::builder()
211                .map_err(|e| format!("ocr: builder: {e}"))?
212                .with_intra_threads(1)
213                .map_err(|e| format!("ocr: intra_threads: {e}"))?;
214            let builder = docling_onnx::apply(builder).map_err(|e| format!("ocr: {e}"))?;
215            docling_onnx::commit(builder, &rec_path, "rec")
216                .map_err(|e| format!("ocr: load {rec_path}: {e}"))
217        };
218        // The lanes are independent sessions over the same file — open them
219        // concurrently so extra lanes cost no extra start-up latency.
220        let recs: Vec<Session> = std::thread::scope(|s| {
221            let handles: Vec<_> = (0..lanes).map(|_| s.spawn(open)).collect();
222            handles
223                .into_iter()
224                .map(|h| {
225                    h.join()
226                        .map_err(|_| "ocr: session thread panicked".to_string())?
227                })
228                .collect::<Result<Vec<_>, String>>()
229        })?;
230        let dict = std::fs::read_to_string(&dict_path)
231            .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
232        Ok(Self {
233            recs,
234            chars: dict_chars(&dict),
235        })
236    }
237
238    /// Recognise every width batch of `lines`, dealt round-robin across the
239    /// lanes, and return `(line index, (text, confidence))` in batch order —
240    /// the same order the sequential loop produced, whatever the scheduling.
241    fn recognize_all(&mut self, lines: &[PrepLine]) -> Result<Vec<(usize, Recognized)>, String> {
242        let batches = width_batches(lines);
243        let lanes = self.recs.len().min(batches.len()).max(1);
244        let chars = &self.chars;
245        // One result slot per batch keeps the merge order independent of
246        // which lane finished first.
247        let mut per_batch: Vec<Option<Result<Vec<Recognized>, String>>> =
248            (0..batches.len()).map(|_| None).collect();
249        if lanes <= 1 {
250            for (slot, (w, chunk)) in per_batch.iter_mut().zip(&batches) {
251                *slot = Some(recognize_batch(&mut self.recs[0], chars, *w, chunk, lines));
252            }
253        } else {
254            std::thread::scope(|s| {
255                let handles: Vec<_> = self
256                    .recs
257                    .iter_mut()
258                    .take(lanes)
259                    .enumerate()
260                    .map(|(lane, rec)| {
261                        let batches = &batches;
262                        s.spawn(move || {
263                            batches
264                                .iter()
265                                .enumerate()
266                                .filter(|(k, _)| k % lanes == lane)
267                                .map(|(k, (w, chunk))| {
268                                    (k, recognize_batch(rec, chars, *w, chunk, lines))
269                                })
270                                .collect::<Vec<_>>()
271                        })
272                    })
273                    .collect();
274                for h in handles {
275                    for (k, r) in h.join().expect("ocr lane panicked") {
276                        per_batch[k] = Some(r);
277                    }
278                }
279            });
280        }
281        let mut out = Vec::with_capacity(lines.len());
282        for ((_, chunk), slot) in batches.iter().zip(per_batch) {
283            let texts = slot.expect("every batch is assigned a lane")?;
284            out.extend(chunk.iter().copied().zip(texts));
285        }
286        Ok(out)
287    }
288
289    /// Recognise a batch of prepared *same-width* lines in one session run.
290    ///
291    /// Only equal widths ever share a run: same-width batching is
292    /// bit-identical to one-at-a-time recognition (each sample keeps its own
293    /// data and per-sample kernel reduction order — verified empirically on
294    /// the scanned corpus), whereas width-padding leaks into the real
295    /// timesteps through the model's global-attention blocks and measurably
296    /// changes low-confidence characters.
297    /// Recognize `lines` and reduce to orientation-probe evidence: the
298    /// confidence-weighted character count `Σ(conf × chars)` plus the raw
299    /// character total (#225). Same deterministic width-batching as page OCR.
300    pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
301        // Same accumulation order as the sequential loop (batch order), so
302        // the f32 sum is bit-identical regardless of lane scheduling.
303        let mut weighted = 0.0f32;
304        let mut chars = 0usize;
305        for (_, (text, conf)) in self.recognize_all(lines)? {
306            let n = text.trim().chars().count();
307            weighted += conf * n as f32;
308            chars += n;
309        }
310        Ok((weighted, chars))
311    }
312
313    /// OCR a page: produce text cells (page points) for every line found inside
314    /// the text regions, each paired with its recognition confidence (mean
315    /// emitted-character probability — feeds the page `ocr_score`, #183).
316    /// `scale` is image-px per page-point.
317    pub fn ocr_page(
318        &mut self,
319        img: &RgbImage,
320        regions: &[Region],
321        scale: f32,
322    ) -> Result<Vec<(TextCell, f32)>, String> {
323        // Gather every line crop on the page first (shared with the browser
324        // path), so equal-width lines can share a recognition run regardless
325        // of which region they came from.
326        let (bboxes, lines) =
327            crate::timing::timed("ocr.prep", || prep_region_lines(img, regions, scale));
328
329        // Deterministic width-batching (shared with the wasm path), dealt
330        // across the recognition lanes.
331        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
332        crate::timing::timed("ocr.rec", || -> Result<(), String> {
333            for (i, text) in self.recognize_all(&lines)? {
334                texts[i] = text;
335            }
336            Ok(())
337        })?;
338
339        // Emit cells in page order, exactly as the sequential walk did.
340        let mut cells = Vec::new();
341        for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
342            let text = text.trim().to_string();
343            if text.is_empty() {
344                continue;
345            }
346            cells.push((TextCell { text, l, t, r, b }, conf));
347        }
348        Ok(cells)
349    }
350
351    /// Recognize the *word* crops inside the page's table regions (mirroring
352    /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
353    /// skips table labels, so a scanned table would otherwise reach the cell
354    /// matcher with no words at all and dissolve (#173). Returns word-level
355    /// [`TextCell`]s in page points.
356    pub fn ocr_table_words(
357        &mut self,
358        img: &RgbImage,
359        regions: &[Region],
360        scale: f32,
361    ) -> Result<Vec<(TextCell, f32)>, String> {
362        let (bboxes, lines) = prep_table_words(img, regions, scale);
363        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
364        for (i, text) in self.recognize_all(&lines)? {
365            texts[i] = text;
366        }
367        let mut cells = Vec::new();
368        for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
369            let text = text.trim().to_string();
370            if text.is_empty() {
371                continue;
372            }
373            cells.push((TextCell { text, l, t, r, b }, conf));
374        }
375        Ok(cells)
376    }
377}
378
379/// Recognise a batch of prepared *same-width* lines in one run of `rec`.
380///
381/// Only equal widths ever share a run: same-width batching is bit-identical
382/// to one-at-a-time recognition (each sample keeps its own data and per-sample
383/// kernel reduction order — verified empirically on the scanned corpus),
384/// whereas width-padding leaks into the real timesteps through the model's
385/// global-attention blocks and measurably changes low-confidence characters.
386fn recognize_batch(
387    rec: &mut Session,
388    chars: &[String],
389    w: usize,
390    chunk: &[usize],
391    lines: &[PrepLine],
392) -> Result<Vec<(String, f32)>, String> {
393    let n = chunk.len();
394    let data = batch_input(w, chunk, lines);
395    let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
396        .map_err(|e| format!("ocr: input tensor: {e}"))?;
397    let outputs = rec
398        .run(ort::inputs!["x" => input])
399        .map_err(|e| format!("ocr: rec inference: {e}"))?;
400    let (shape, probs) = outputs[0]
401        .try_extract_tensor::<f32>()
402        .map_err(|e| format!("ocr: extract rec: {e}"))?;
403    let t_len = shape[1] as usize;
404    let nc = shape[2] as usize;
405    Ok((0..n)
406        .map(|i| decode_row_scored(chars, &probs[i * t_len * nc..(i + 1) * t_len * nc], nc))
407        .collect())
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    /// #254: docling's four `OcrMode` ids parse; `full_page`/`layout_regions`
415    /// reduce to the force-full-page machinery, the default/pdf-aware pair to
416    /// the standard text-layer-aware path. Unknown ids parse to nothing.
417    #[test]
418    fn ocr_mode_ids_parse_and_map_to_forcing() {
419        for (id, mode, forces) in [
420            ("default", OcrMode::Default, false),
421            ("full_page", OcrMode::FullPage, true),
422            ("layout_regions", OcrMode::LayoutRegions, true),
423            (
424                "pdf_aware_layout_regions",
425                OcrMode::PdfAwareLayoutRegions,
426                false,
427            ),
428        ] {
429            assert_eq!(OcrMode::parse(id), Some(mode));
430            assert_eq!(mode.forces_full_page(), forces, "{id}");
431        }
432        assert_eq!(OcrMode::parse(" Full_Page "), Some(OcrMode::FullPage));
433        assert_eq!(OcrMode::parse("easyocr"), None);
434        assert_eq!(OcrMode::parse(""), None);
435    }
436}