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 locates the text regions on the page image
3//! (it works without a text layer); inside them the recognizer reads the
4//! lines the PP-OCR text detector found (`ocr_det`, #429/#570 — RapidOCR's
5//! own crops, each one text run with the detector's margin), falling back to
6//! a horizontal-projection split of the region crop where the detector saw
7//! nothing or is not installed; each line is recognised and decoded with CTC
8//! — producing [`TextCell`]s the normal layout assembly then consumes. A line
9//! under RapidOCR's `text_score` confidence is dropped (`ocr_prep::text_score`).
10
11use image::RgbImage;
12use ort::session::Session;
13use ort::value::Tensor;
14
15use crate::layout::Region;
16// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
17// so the wasm build shares it verbatim (#79 phase 2).
18use crate::ocr_prep::{
19    batch_input, decode_row_scored, dict_chars, prep_region_lines, prep_region_lines_det,
20    prep_table_words, prep_table_words_det, width_batches, PrepLine, REC_HEIGHT,
21};
22use crate::pdfium_backend::TextCell;
23
24pub struct OcrModel {
25    /// Single-threaded recognition sessions, one per parallel lane (see
26    /// [`Self::load_with`]); lines are dealt across them by batch index.
27    recs: Vec<Session>,
28    /// CTC classes: index 0 = blank, then the dictionary, then space.
29    chars: Vec<String>,
30    /// RapidOCR's line orientation handling (#666). See
31    /// [`crate::Pipeline::ocr_textline_orientation`].
32    orient: bool,
33    /// The text-line orientation classifier, loaded on the first oriented
34    /// page.
35    cls: ClsSlot,
36}
37
38/// [`OcrModel::cls`]: `Missing` records a failed load (warned once per
39/// process), so it is not retried per page.
40enum ClsSlot {
41    Unloaded,
42    Ready(crate::ocr_cls::TextlineCls),
43    Missing,
44}
45
46/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs
47/// when the PP-OCRv6 recognizer is not installed.
48///
49/// With `.models/ocr_rec_v6.onnx` + `.models/ocr_rec_v6_dict.txt` on disk
50/// (#570; `download_dependencies.sh` fetches them) both languages run that
51/// one multilingual model — RapidOCR's `PP-OCRv6_rec_small`, the recognizer
52/// docling runs for English and Chinese alike, and the single largest factor
53/// in the FUNSD word-recall gap once the detector's lines are the crops
54/// (0.70 → 0.77 on 30 forms). Without it, the PP-OCRv3 pairs: the default is
55/// **English** (`.models/ocr_rec_en.onnx` + `.models/en_dict.txt`) — the
56/// multilingual `ch_` v3 model reads Latin scripts with badly degraded word
57/// spacing (glued words on ordinary English scans) — and `Ch` selects the
58/// `ch_` pair (`.models/ocr_rec.onnx` + `.models/ppocr_keys_v1.txt`), what
59/// the PDF conformance baselines were pinned against;
60/// `scripts/conformance/pdf_*.sh` pin it explicitly by path, which wins over
61/// this selector and over the v6 preference.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum OcrLang {
64    /// en_PP-OCRv3 — English-only, proper Latin word spacing.
65    #[default]
66    En,
67    /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
68    Ch,
69}
70
71impl OcrLang {
72    /// Parse a user-supplied language id (#388): the engine's own codes
73    /// (`en`, `ch`) and BCP-47 tags naming a language one of the two
74    /// recognizers reads, trimmed and case-insensitive. `None` for anything
75    /// else — callers surface their own error/warning.
76    ///
77    /// docling canonicalizes OCR languages across its engines (docling#4075):
78    /// a bare value is the engine's native code, an `iso:`-prefixed value a
79    /// BCP-47 tag reduced to a language-script pair with the region dropped
80    /// (`zh-CN` and `zh-Hans` are the same recognizer, `en-GB` is `en`), and
81    /// the RapidOCR adapter maps `en` → its `en` model and `zh-Hans` → `ch`.
82    /// With only those two PP-OCRv3 pairs on board there is no ambiguity, so
83    /// the prefix is optional here: `en-US`, `eng`, `zh`, `zh-Hans`, `iso:zh-CN`
84    /// all resolve without a warning. Accepted primary subtags: English as
85    /// `en` / ISO 639-2/3 `eng` / docling's legacy `english`; Chinese as the
86    /// engine code `ch` (and RapidOCR's `chinese_cht`), `zh` / `zho` / `chi` /
87    /// `cmn` / legacy `chinese` / EasyOCR's `ch_sim` / `ch_tra`. Script,
88    /// region and variant subtags (`-Hans`, `-Hant`, `-CN`, `-TW`, `_US`) are
89    /// ignored: a traditional-script request (`zh-Hant`, `zh-TW`) gets the
90    /// multilingual `ch` recognizer too, the closest model shipped — upstream
91    /// would pick RapidOCR's separate `chinese_cht`, which this engine does
92    /// not carry. Genuinely unsupported languages (`de`, `fr`, `ja`, …) parse
93    /// to `None` and keep warning.
94    pub fn parse(s: &str) -> Option<Self> {
95        let token = s.trim().to_ascii_lowercase();
96        let tag = token.strip_prefix("iso:").unwrap_or(&token).trim();
97        let primary = tag.split(['-', '_']).next().unwrap_or_default();
98        match primary {
99            "en" | "eng" | "english" => Some(Self::En),
100            "ch" | "chinese_cht" | "zh" | "zho" | "chi" | "cmn" | "chinese" | "ch_sim"
101            | "ch_tra" => Some(Self::Ch),
102            _ => None,
103        }
104    }
105
106    /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
107    /// English default; unknown values warn and use English). Under a
108    /// `DOCLING_RS_OCR_ENGINE=tesseract` default the variable is Tesseract's
109    /// language list (`rus+eng`, [`crate::tesseract::lang_arg`]), not a
110    /// PP-OCR model choice: a value naming no en/ch model then selects the
111    /// English model silently — it is only used if a request switches the
112    /// engine back to PP-OCR — instead of warning on every request (#889).
113    pub fn from_env() -> Self {
114        let raw = docling_core::env::nonempty("DOCLING_RS_OCR_LANG");
115        Self::from_env_value(raw.as_deref(), OcrEngine::from_env)
116    }
117
118    /// [`Self::from_env`] on the variable's value and the default engine
119    /// (asked only when the value names no model).
120    fn from_env_value(raw: Option<&str>, engine: impl Fn() -> OcrEngine) -> Self {
121        let Some(raw) = raw else {
122            return Self::default();
123        };
124        Self::parse(raw).unwrap_or_else(|| {
125            if engine() == OcrEngine::Tesseract {
126                return Self::default();
127            }
128            eprintln!(
129                "docling-pdf: DOCLING_RS_OCR_LANG={raw:?} names no language the en/ch \
130                 recognizers read ({}); using en",
131                Self::ACCEPTED
132            );
133            Self::default()
134        })
135    }
136
137    /// The accepted spellings, for error messages and docs.
138    pub const ACCEPTED: &'static str =
139        "en | ch, or a BCP-47 tag for English or Chinese such as en-US, eng, zh, zh-Hans, zh-TW";
140}
141
142/// Which document regions feed the OCR — docling 2.116's `OcrMode` (#254,
143/// upstream docling#3710). Upstream restructured its pipeline so OCR runs
144/// *after* layout, on layout regions filtered by the PDF text layer — the
145/// architecture this port has always had — and named the strategies:
146///
147/// - `PdfAwareLayoutRegions` (upstream's **default**): OCR only layout regions
148///   the embedded text layer can't cover. Exactly the standard path here —
149///   scanned pages OCR their regions, digital pages OCR only text-less bitmap
150///   areas.
151/// - `FullPage` / `LayoutRegions`: ignore the PDF text layer and OCR
152///   everything. Both map onto the [`force_full_page_ocr`] machinery (discard
153///   the text layer, OCR every layout region): the upstream distinction —
154///   whole-page vs per-region *detector* input — has no analogue in this
155///   engine, whose PP-OCR recognizer always consumes per-region line crops.
156///
157/// [`force_full_page_ocr`]: crate::Pipeline::force_full_page_ocr
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159pub enum OcrMode {
160    /// Upstream's `default`: currently wired to `PdfAwareLayoutRegions`.
161    #[default]
162    Default,
163    /// OCR the full page, text layer ignored (docling's `full_page`; the
164    /// mode-shaped spelling of `force_full_page_ocr`).
165    FullPage,
166    /// OCR every layout region, text layer ignored (docling's
167    /// `layout_regions`).
168    LayoutRegions,
169    /// OCR layout regions the text layer can't cover (docling's
170    /// `pdf_aware_layout_regions` — the default behavior).
171    PdfAwareLayoutRegions,
172}
173
174impl OcrMode {
175    /// Parse docling's mode ids. `None` for anything else — callers surface
176    /// their own error/warning.
177    pub fn parse(s: &str) -> Option<Self> {
178        match s.trim().to_ascii_lowercase().as_str() {
179            "default" => Some(Self::Default),
180            "full_page" => Some(Self::FullPage),
181            "layout_regions" => Some(Self::LayoutRegions),
182            "pdf_aware_layout_regions" => Some(Self::PdfAwareLayoutRegions),
183            _ => None,
184        }
185    }
186
187    /// The process-level choice from `DOCLING_RS_OCR_MODE` (empty/unset → the
188    /// default; unknown values warn and use the default).
189    pub fn from_env() -> Self {
190        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_MODE") else {
191            return Self::default();
192        };
193        Self::parse(&raw).unwrap_or_else(|| {
194            eprintln!(
195                "docling-pdf: DOCLING_RS_OCR_MODE={raw:?} is not \
196                 default|full_page|layout_regions|pdf_aware_layout_regions; using default"
197            );
198            Self::default()
199        })
200    }
201
202    /// Whether this mode discards the embedded text layer — the engine truth
203    /// both non-default modes reduce to.
204    pub fn forces_full_page(self) -> bool {
205        matches!(self, Self::FullPage | Self::LayoutRegions)
206    }
207}
208
209/// Which OCR engine recognizes text (#460): the built-in PP-OCRv3 recognizer
210/// (+ the RapidOCR text detector) — the default and the engine every
211/// conformance baseline is pinned against — or the system `tesseract` binary
212/// (see [`crate::tesseract`]), docling's `TesseractCliOcrOptions`
213/// counterpart. Both consume the same layout-region crops and produce the
214/// same cells; everything downstream is engine-agnostic.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
216pub enum OcrEngine {
217    /// PP-OCRv3 recognition via ONNX Runtime (docling's `rapidocr` kind).
218    #[default]
219    PpOcr,
220    /// The `tesseract` CLI (docling's `tesseract` kind).
221    Tesseract,
222}
223
224impl OcrEngine {
225    /// Parse an engine id: `ppocr` (also `pp-ocr`, `rapidocr`, docling's
226    /// kind name) or `tesseract` (also `tesseract_cli`, `tesserocr`),
227    /// trimmed and case-insensitive. `None` for anything else.
228    pub fn parse(s: &str) -> Option<Self> {
229        match s.trim().to_ascii_lowercase().as_str() {
230            "ppocr" | "pp-ocr" | "pp_ocr" | "rapidocr" | "onnx" | "default" => Some(Self::PpOcr),
231            "tesseract" | "tesseract_cli" | "tesseract-cli" | "tesserocr" => Some(Self::Tesseract),
232            _ => None,
233        }
234    }
235
236    /// The process-level choice from `DOCLING_RS_OCR_ENGINE` (empty/unset →
237    /// PP-OCR; unknown values warn and use PP-OCR).
238    pub fn from_env() -> Self {
239        let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_ENGINE") else {
240            return Self::default();
241        };
242        Self::parse(&raw).unwrap_or_else(|| {
243            eprintln!(
244                "docling-pdf: DOCLING_RS_OCR_ENGINE={raw:?} is not ppocr|tesseract; using ppocr"
245            );
246            Self::default()
247        })
248    }
249
250    /// The accepted spellings, for error messages and docs.
251    pub const ACCEPTED: &'static str = "ppocr | tesseract";
252
253    /// Whether `raw` is an `ocr_lang` this engine can act on — what the
254    /// option surfaces validate up front: the en/ch model switch (or a
255    /// BCP-47 tag for either, [`OcrLang::parse`]) under PP-OCR; tessdata
256    /// stems and BCP-47 tags ([`crate::tesseract::lang_arg`]) under
257    /// Tesseract. The `Err` says what is accepted.
258    pub fn validate_lang(self, raw: &str) -> Result<(), String> {
259        match self {
260            Self::PpOcr => OcrLang::parse(raw).map(|_| ()).ok_or_else(|| {
261                format!(
262                    "ocr_lang {raw:?} names no language the OCR models read ({})",
263                    OcrLang::ACCEPTED
264                )
265            }),
266            Self::Tesseract => crate::tesseract::lang_arg(raw).map(|_| ()),
267        }
268    }
269}
270
271/// The process-level OCR render scale from `DOCLING_RS_OCR_SCALE` (#254,
272/// upstream docling#3877's `OcrOptions.scale`): pixels per PDF point fed to
273/// the recognizer. Unset/empty → `None` (OCR reads the pipeline's own page
274/// render, 2.0 px/pt); non-positive or unparsable values warn and are ignored.
275pub fn scale_from_env() -> Option<f32> {
276    positive_scale_env("DOCLING_RS_OCR_SCALE")
277}
278
279/// The process-level picture OCR scale from `DOCLING_RS_PICTURE_OCR_SCALE`
280/// (#666): pixels per picture pixel for the picture-OCR enrichment, before
281/// RapidOCR's 2000 px cap — see [`crate::Pipeline::picture_ocr_scale`].
282/// Unset/empty → `None`; non-positive or unparsable values warn and are
283/// ignored.
284pub fn picture_scale_from_env() -> Option<f32> {
285    positive_scale_env("DOCLING_RS_PICTURE_OCR_SCALE")
286}
287
288fn positive_scale_env(name: &str) -> Option<f32> {
289    let raw = docling_core::env::nonempty(name)?;
290    match raw.parse::<f32>() {
291        Ok(s) if s > 0.0 && s.is_finite() => Some(s),
292        _ => {
293            eprintln!("docling-pdf: {name}={raw:?} is not a positive number; ignored");
294            None
295        }
296    }
297}
298
299/// The process-level default of [`crate::Pipeline::ocr_textline_orientation`]
300/// from `DOCLING_RS_OCR_TEXTLINE_ORIENTATION` (#666; a `docling_core::env`
301/// flag). Off when unset.
302pub fn textline_orientation_from_env() -> bool {
303    docling_core::env::flag("DOCLING_RS_OCR_TEXTLINE_ORIENTATION")
304}
305
306/// Whether the text detector's boxes are the recognizer's line source inside
307/// layout regions (#570; `DOCLING_RS_OCR_LINES`: `det` default, `projection`
308/// = the ink-projection strips alone, the pre-#570 behavior). Cached.
309pub fn det_lines() -> bool {
310    static MODE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
311    *MODE.get_or_init(|| {
312        let raw = docling_core::env::nonempty("DOCLING_RS_OCR_LINES").unwrap_or_default();
313        match raw.trim().to_ascii_lowercase().as_str() {
314            "" | "det" | "detector" | "auto" => true,
315            "projection" | "proj" | "off" => false,
316            _ => {
317                eprintln!(
318                    "docling-pdf: DOCLING_RS_OCR_LINES={raw:?} is not det|projection; using det"
319                );
320                true
321            }
322        }
323    })
324}
325
326/// Resolve the recognition model + dictionary pair for `lang`. An English
327/// default that isn't on disk (older model checkouts) degrades to the `ch_`
328/// pair with a warning rather than failing — the usual missing-optional-asset
329/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
330/// over all of this; they are a pair, so set both together.
331pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
332    const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
333    const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
334    const V6: (&str, &str) = (".models/ocr_rec_v6.onnx", ".models/ocr_rec_v6_dict.txt");
335    let exists = |p: &str| std::path::Path::new(p).exists();
336    // The multilingual PP-OCRv6 recognizer, when installed, serves both
337    // languages (see `OcrLang`); explicit paths below still win.
338    let (v6_rec, v6_dict) = (crate::resolve_asset(V6.0), crate::resolve_asset(V6.1));
339    let (mut rec, mut dict) = if exists(&v6_rec) && exists(&v6_dict) {
340        (v6_rec, v6_dict)
341    } else {
342        let pick = if lang == OcrLang::Ch { CH } else { EN };
343        (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1))
344    };
345    let want_ch = lang == OcrLang::Ch;
346    if !want_ch && (!exists(&rec) || !exists(&dict)) {
347        let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
348        if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
349            eprintln!(
350                "docling-pdf: English OCR model not found ({rec}); falling back to the \
351                 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
352                 scripts/install/download_dependencies.sh"
353            );
354            (rec, dict) = (ch_rec, ch_dict);
355        }
356    }
357    (
358        docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
359        docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
360    )
361}
362
363/// One recognised line: its text and mean emitted-character confidence.
364type Recognized = (String, f32);
365
366impl OcrModel {
367    /// Load the recognition model and its character dictionary for `lang` —
368    /// see [`resolve_rec_pair`] for the selection rules (explicit
369    /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win) — with `lanes`
370    /// recognition sessions.
371    ///
372    /// Each session is pinned to one intra-op thread: ORT's multi-threaded
373    /// float-reduction order varies across runs, which flips the CTC argmax on
374    /// low-confidence characters (e.g. noisy faxes) and makes the snapshot
375    /// output non-deterministic. Recognition is linear in line width (~0.17 ms
376    /// per pixel column on one core) and on a scanned page it, plus the
377    /// orientation probe that reads the six widest lines, is ~35% of the wall
378    /// time while the other cores idle. Lines are independent, so `lanes`
379    /// sessions recognise disjoint same-width batches concurrently — each
380    /// line still sees exactly the single-thread kernel path, results are
381    /// placed by index, and the output is byte-identical to one lane.
382    /// `DOCLING_RS_OCR_SESSIONS` overrides the caller's lane count; under a
383    /// CUDA-class provider the default is one lane (#842) — the lanes exist
384    /// to use idle CPU cores, and on one GPU each is another session's arena.
385    pub fn load_with(lang: OcrLang, lanes: usize) -> Result<Self, String> {
386        let (rec_path, dict_path) = resolve_rec_pair(lang);
387        let lanes = docling_core::env::parse::<usize>("DOCLING_RS_OCR_SESSIONS")
388            .filter(|&n| n > 0)
389            .unwrap_or(if docling_onnx::prefers_single_worker() {
390                1
391            } else {
392                lanes
393            })
394            .clamp(1, 8);
395        let open = || -> Result<Session, String> {
396            let builder = docling_onnx::session_builder()
397                .map_err(|e| format!("ocr: builder: {e}"))?
398                .with_intra_threads(1)
399                .map_err(|e| format!("ocr: intra_threads: {e}"))?;
400            let builder = docling_onnx::apply(builder).map_err(|e| format!("ocr: {e}"))?;
401            docling_onnx::commit(builder, &rec_path, "rec")
402                .map_err(|e| format!("ocr: load {rec_path}: {e}"))
403        };
404        // The lanes are independent sessions over the same file — open them
405        // concurrently so extra lanes cost no extra start-up latency.
406        let recs: Vec<Session> = std::thread::scope(|s| {
407            let handles: Vec<_> = (0..lanes).map(|_| s.spawn(open)).collect();
408            handles
409                .into_iter()
410                .map(|h| {
411                    h.join()
412                        .map_err(|_| "ocr: session thread panicked".to_string())?
413                })
414                .collect::<Result<Vec<_>, String>>()
415        })?;
416        let dict = std::fs::read_to_string(&dict_path)
417            .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
418        Ok(Self {
419            recs,
420            chars: dict_chars(&dict),
421            orient: false,
422            cls: ClsSlot::Unloaded,
423        })
424    }
425
426    /// Turn RapidOCR's line orientation handling on or off (#666): oriented
427    /// crops, the 90° turn, and the 0/180 classifier on detector lines.
428    pub fn set_textline_orientation(&mut self, enable: bool) {
429        self.orient = enable;
430    }
431
432    /// The orientation classifier, loading it on first use; `None` (warned
433    /// once) when the model is not installed or fails to load.
434    fn cls(&mut self) -> Option<&mut crate::ocr_cls::TextlineCls> {
435        if matches!(self.cls, ClsSlot::Unloaded) {
436            self.cls = match crate::ocr_cls::TextlineCls::load() {
437                Ok(m) => ClsSlot::Ready(m),
438                Err(e) => {
439                    static WARNED: std::sync::Once = std::sync::Once::new();
440                    WARNED.call_once(|| {
441                        eprintln!(
442                            "warning: {e}; text lines are cropped and turned upright but not \
443                             checked for 180° (run scripts/install/download_dependencies.sh)"
444                        );
445                    });
446                    ClsSlot::Missing
447                }
448            };
449        }
450        match &mut self.cls {
451            ClsSlot::Ready(m) => Some(m),
452            _ => None,
453        }
454    }
455
456    /// The oriented line prep (#666): RapidOCR's crops, the upside-down ones
457    /// turned 180° by the classifier when it is installed, then prepared
458    /// for recognition like any other line.
459    fn oriented_lines(
460        &mut self,
461        img: &RgbImage,
462        regions: &[Region],
463        scale: f32,
464        detected: &[crate::ocr_det::DetBox],
465    ) -> Result<(Vec<crate::ocr_prep::LineBox>, Vec<PrepLine>), String> {
466        use crate::ocr_prep::LineSrc;
467        let (bboxes, mut srcs) =
468            crate::ocr_prep::region_line_crops_det(img, regions, scale, detected);
469        let crop_idx: Vec<usize> = (0..srcs.len())
470            .filter(|&i| matches!(srcs[i], LineSrc::Crop(_)))
471            .collect();
472        if !crop_idx.is_empty() {
473            if let Some(cls) = self.cls() {
474                let crops: Vec<&RgbImage> = crop_idx
475                    .iter()
476                    .map(|&i| match &srcs[i] {
477                        LineSrc::Crop(c) => c,
478                        LineSrc::Prepared(_) => unreachable!("filtered to crops"),
479                    })
480                    .collect();
481                let flip = crate::timing::timed("ocr.cls", || cls.upside_down(&crops))?;
482                for (&i, flip) in crop_idx.iter().zip(flip) {
483                    if let (true, LineSrc::Crop(c)) = (flip, &mut srcs[i]) {
484                        image::imageops::rotate180_in_place(c);
485                    }
486                }
487            }
488        }
489        let mut boxes = Vec::with_capacity(bboxes.len());
490        let mut lines = Vec::with_capacity(bboxes.len());
491        for (bb, src) in bboxes.into_iter().zip(srcs) {
492            let line = match src {
493                LineSrc::Crop(c) => crate::ocr_prep::prep_line(&c),
494                LineSrc::Prepared(p) => Some(p),
495            };
496            if let Some(line) = line {
497                boxes.push(bb);
498                lines.push(line);
499            }
500        }
501        Ok((boxes, lines))
502    }
503
504    /// Recognise every width batch of `lines`, dealt round-robin across the
505    /// lanes, and return `(line index, (text, confidence))` in batch order —
506    /// the same order the sequential loop produced, whatever the scheduling.
507    fn recognize_all(&mut self, lines: &[PrepLine]) -> Result<Vec<(usize, Recognized)>, String> {
508        let batches = width_batches(lines);
509        let lanes = self.recs.len().min(batches.len()).max(1);
510        let chars = &self.chars;
511        // One result slot per batch keeps the merge order independent of
512        // which lane finished first.
513        let mut per_batch: Vec<Option<Result<Vec<Recognized>, String>>> =
514            (0..batches.len()).map(|_| None).collect();
515        if lanes <= 1 {
516            for (slot, (w, chunk)) in per_batch.iter_mut().zip(&batches) {
517                *slot = Some(recognize_batch(&mut self.recs[0], chars, *w, chunk, lines));
518            }
519        } else {
520            std::thread::scope(|s| {
521                let handles: Vec<_> = self
522                    .recs
523                    .iter_mut()
524                    .take(lanes)
525                    .enumerate()
526                    .map(|(lane, rec)| {
527                        let batches = &batches;
528                        s.spawn(move || {
529                            batches
530                                .iter()
531                                .enumerate()
532                                .filter(|(k, _)| k % lanes == lane)
533                                .map(|(k, (w, chunk))| {
534                                    (k, recognize_batch(rec, chars, *w, chunk, lines))
535                                })
536                                .collect::<Vec<_>>()
537                        })
538                    })
539                    .collect();
540                for h in handles {
541                    for (k, r) in h.join().expect("ocr lane panicked") {
542                        per_batch[k] = Some(r);
543                    }
544                }
545            });
546        }
547        let mut out = Vec::with_capacity(lines.len());
548        for ((_, chunk), slot) in batches.iter().zip(per_batch) {
549            let texts = slot.expect("every batch is assigned a lane")?;
550            out.extend(chunk.iter().copied().zip(texts));
551        }
552        Ok(out)
553    }
554
555    /// Recognise a batch of prepared *same-width* lines in one session run.
556    ///
557    /// Only equal widths ever share a run: same-width batching is
558    /// bit-identical to one-at-a-time recognition (each sample keeps its own
559    /// data and per-sample kernel reduction order — verified empirically on
560    /// the scanned corpus), whereas width-padding leaks into the real
561    /// timesteps through the model's global-attention blocks and measurably
562    /// changes low-confidence characters.
563    /// Recognize `lines` and reduce to orientation-probe evidence: the
564    /// confidence-weighted character count `Σ(conf × chars)` plus the raw
565    /// character total (#225). Same deterministic width-batching as page OCR.
566    pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
567        // Same accumulation order as the sequential loop (batch order), so
568        // the f32 sum is bit-identical regardless of lane scheduling.
569        let mut weighted = 0.0f32;
570        let mut chars = 0usize;
571        for (_, (text, conf)) in self.recognize_all(lines)? {
572            let n = text.trim().chars().count();
573            weighted += conf * n as f32;
574            chars += n;
575        }
576        Ok((weighted, chars))
577    }
578
579    /// OCR a page: produce text cells (page points) for every line found inside
580    /// the text regions, each paired with its recognition confidence (mean
581    /// emitted-character probability — feeds the page `ocr_score`, #183).
582    /// `scale` is image-px per page-point. `detected` — the text detector's
583    /// boxes, in image pixels of `img` — makes them the line source inside
584    /// the regions (#570, see [`prep_region_lines_det`]); `None` keeps the
585    /// projection segmentation.
586    pub fn ocr_page_with(
587        &mut self,
588        img: &RgbImage,
589        regions: &[Region],
590        scale: f32,
591        detected: Option<&[crate::ocr_det::DetBox]>,
592    ) -> Result<Vec<(TextCell, f32)>, String> {
593        // Gather every line crop on the page first (shared with the browser
594        // path), so equal-width lines can share a recognition run regardless
595        // of which region they came from.
596        let (bboxes, lines) = match detected {
597            Some(det) if self.orient => self.oriented_lines(img, regions, scale, det)?,
598            _ => crate::timing::timed("ocr.prep", || match detected {
599                Some(det) => prep_region_lines_det(img, regions, scale, det),
600                None => prep_region_lines(img, regions, scale),
601            }),
602        };
603
604        // Deterministic width-batching (shared with the wasm path), dealt
605        // across the recognition lanes.
606        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
607        crate::timing::timed("ocr.rec", || -> Result<(), String> {
608            for (i, text) in self.recognize_all(&lines)? {
609                texts[i] = text;
610            }
611            Ok(())
612        })?;
613
614        // Emit cells in page order, exactly as the sequential walk did.
615        Ok(collect_cells(bboxes, texts))
616    }
617
618    /// Recognize the *word* crops inside the page's table regions (mirroring
619    /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
620    /// skips table labels, so a scanned table would otherwise reach the cell
621    /// matcher with no words at all and dissolve (#173). Returns word-level
622    /// [`TextCell`]s in page points.
623    pub fn ocr_table_words(
624        &mut self,
625        img: &RgbImage,
626        regions: &[Region],
627        scale: f32,
628        detected: Option<&[crate::ocr_det::DetBox]>,
629    ) -> Result<Vec<(TextCell, f32)>, String> {
630        let (bboxes, lines) = match detected {
631            Some(det) => prep_table_words_det(img, regions, scale, det),
632            None => prep_table_words(img, regions, scale),
633        };
634        let mut texts = vec![(String::new(), 0.0f32); lines.len()];
635        for (i, text) in self.recognize_all(&lines)? {
636            texts[i] = text;
637        }
638        Ok(collect_cells(bboxes, texts))
639    }
640}
641
642/// Pair recognized texts with their line boxes into page-point cells, in page
643/// order, dropping empty lines and those under [`text_score`].
644fn collect_cells(
645    bboxes: Vec<crate::ocr_prep::LineBox>,
646    texts: Vec<Recognized>,
647) -> Vec<(TextCell, f32)> {
648    let min_conf = crate::ocr_prep::text_score();
649    let mut cells = Vec::new();
650    for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
651        let text = text.trim().to_string();
652        if text.is_empty() || conf < min_conf {
653            continue;
654        }
655        cells.push((TextCell { text, l, t, r, b }, conf));
656    }
657    cells
658}
659
660/// Recognise a batch of prepared *same-width* lines in one run of `rec`.
661///
662/// Only equal widths ever share a run: same-width batching is bit-identical
663/// to one-at-a-time recognition (each sample keeps its own data and per-sample
664/// kernel reduction order — verified empirically on the scanned corpus),
665/// whereas width-padding leaks into the real timesteps through the model's
666/// global-attention blocks and measurably changes low-confidence characters.
667fn recognize_batch(
668    rec: &mut Session,
669    chars: &[String],
670    w: usize,
671    chunk: &[usize],
672    lines: &[PrepLine],
673) -> Result<Vec<(String, f32)>, String> {
674    let n = chunk.len();
675    let data = batch_input(w, chunk, lines);
676    let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
677        .map_err(|e| format!("ocr: input tensor: {e}"))?;
678    let outputs = docling_onnx::run(rec, ort::inputs!["x" => input])
679        .map_err(|e| format!("ocr: rec inference: {e}"))?;
680    let (shape, probs) = outputs[0]
681        .try_extract_tensor::<f32>()
682        .map_err(|e| format!("ocr: extract rec: {e}"))?;
683    let t_len = shape[1] as usize;
684    let nc = shape[2] as usize;
685    Ok((0..n)
686        .map(|i| decode_row_scored(chars, &probs[i * t_len * nc..(i + 1) * t_len * nc], nc))
687        .collect())
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    /// #889: under a Tesseract default, `DOCLING_RS_OCR_LANG` is Tesseract's
695    /// language list — a value naming no en/ch model picks English without
696    /// the PP-OCR warning; under PP-OCR an en/ch value still selects it.
697    #[test]
698    fn env_lang_under_a_tesseract_default_is_not_a_model_choice() {
699        let tess = || OcrEngine::Tesseract;
700        let pp = || OcrEngine::PpOcr;
701        assert_eq!(OcrLang::from_env_value(Some("rus+eng"), tess), OcrLang::En);
702        assert_eq!(OcrLang::from_env_value(Some("rus+eng"), pp), OcrLang::En);
703        assert_eq!(OcrLang::from_env_value(Some("zh-Hans"), tess), OcrLang::Ch);
704        assert_eq!(OcrLang::from_env_value(None, pp), OcrLang::En);
705    }
706
707    /// #388: BCP-47 tags and the ISO 639-2/3 codes for English and Chinese
708    /// resolve to the two recognizers, with or without docling's `iso:`
709    /// prefix and whatever the script/region subtags; other languages and
710    /// nonsense stay `None`.
711    #[test]
712    fn ocr_lang_accepts_bcp47_tags_for_the_two_recognizers() {
713        for id in [
714            "en",
715            "EN",
716            " en ",
717            "en-US",
718            "en_GB",
719            "eng",
720            "english",
721            "iso:en",
722            "ISO:en-GB",
723            "en-Latn-US",
724        ] {
725            assert_eq!(OcrLang::parse(id), Some(OcrLang::En), "{id:?}");
726        }
727        for id in [
728            "ch",
729            "zh",
730            "zho",
731            "chi",
732            "cmn",
733            "chinese",
734            "ch_sim",
735            "ch_tra",
736            "chinese_cht",
737            "zh-Hans",
738            "zh-Hant",
739            "zh-CN",
740            "zh-TW",
741            "zh-Hant-HK",
742            "zh_SG",
743            "iso:zh-Hans",
744        ] {
745            assert_eq!(OcrLang::parse(id), Some(OcrLang::Ch), "{id:?}");
746        }
747        for id in [
748            "", "de", "fr-FR", "ja", "deu", "cn", "latin", "iso:", "iso:und", "e",
749        ] {
750            assert_eq!(OcrLang::parse(id), None, "{id:?}");
751        }
752    }
753
754    /// #254: docling's four `OcrMode` ids parse; `full_page`/`layout_regions`
755    /// reduce to the force-full-page machinery, the default/pdf-aware pair to
756    /// the standard text-layer-aware path. Unknown ids parse to nothing.
757    #[test]
758    fn ocr_mode_ids_parse_and_map_to_forcing() {
759        for (id, mode, forces) in [
760            ("default", OcrMode::Default, false),
761            ("full_page", OcrMode::FullPage, true),
762            ("layout_regions", OcrMode::LayoutRegions, true),
763            (
764                "pdf_aware_layout_regions",
765                OcrMode::PdfAwareLayoutRegions,
766                false,
767            ),
768        ] {
769            assert_eq!(OcrMode::parse(id), Some(mode));
770            assert_eq!(mode.forces_full_page(), forces, "{id}");
771        }
772        assert_eq!(OcrMode::parse(" Full_Page "), Some(OcrMode::FullPage));
773        assert_eq!(OcrMode::parse("easyocr"), None);
774        assert_eq!(OcrMode::parse(""), None);
775    }
776
777    /// #460: the engine ids, and engine-aware `ocr_lang` validation — `deu`
778    /// is a Tesseract stem, not a PP-OCR model; `en` works under both.
779    #[test]
780    fn ocr_engine_ids_and_lang_validation() {
781        for id in ["ppocr", "PP-OCR", " rapidocr ", "default"] {
782            assert_eq!(OcrEngine::parse(id), Some(OcrEngine::PpOcr), "{id:?}");
783        }
784        for id in ["tesseract", "Tesseract_CLI", "tesserocr"] {
785            assert_eq!(OcrEngine::parse(id), Some(OcrEngine::Tesseract), "{id:?}");
786        }
787        assert_eq!(OcrEngine::parse("easyocr"), None);
788        assert!(OcrEngine::PpOcr.validate_lang("en").is_ok());
789        assert!(OcrEngine::PpOcr.validate_lang("deu").is_err());
790        assert!(OcrEngine::Tesseract.validate_lang("en").is_ok());
791        assert!(OcrEngine::Tesseract.validate_lang("deu+fra").is_ok());
792        assert!(OcrEngine::Tesseract.validate_lang("xx").is_err());
793    }
794}