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