Skip to main content

docling_pdf/
tesseract.rs

1//! Tesseract as an alternative OCR engine (#460) — the system `tesseract`
2//! binary driven like docling's `TesseractOcrCliModel`: a subprocess per
3//! crop, `tsv` output on stdout, no bindings and no build-time dependency.
4//! Selected with `DOCLING_RS_OCR_ENGINE=tesseract` / `--ocr-engine tesseract`;
5//! PP-OCRv3 stays the default and the conformance engine.
6//!
7//! It plugs into the pipeline where the PP-OCR recognizer does — the same
8//! layout-region crops, the same [`TextCell`] output in page points — so
9//! everything downstream (orphan recovery, TableFormer word matching, the page
10//! `ocr_score`) is engine-agnostic. Differences that follow from the engine:
11//!
12//! - Tesseract segments its own lines and words inside a region crop, so the
13//!   projection-profile line splitter (`ocr_prep`) is not used; a region's
14//!   cells are its `tsv` lines (words joined by single spaces), a table's
15//!   cells its words — the granularity each consumer expects.
16//! - Orientation (#225) comes from Tesseract's own OSD (`--psm 0 -l osd`,
17//!   needs the `osd` traineddata) instead of the recognize-four-ways probe.
18//! - Languages are tessdata stems (`eng`, `deu+fra`, `script/Cyrillic`), not
19//!   the en/ch model switch — see [`lang_arg`] for what `ocr_lang` accepts
20//!   under this engine.
21//!
22//! Every crop runs as its own `tesseract` process, dealt across the worker's
23//! OCR lanes (`intra` threads, `DOCLING_RS_OCR_SESSIONS` overrides); the
24//! child is pinned to one OpenMP thread so the lanes, not libgomp, own the
25//! parallelism. Environment: `DOCLING_TESSERACT` (the binary; default
26//! `tesseract` on `PATH`), `DOCLING_RS_TESSERACT_PSM` (page segmentation
27//! mode 0–13, unset = Tesseract's default), `DOCLING_RS_TESSDATA_DIR`
28//! (`--tessdata-dir`; `TESSDATA_PREFIX` is honored by Tesseract itself).
29//! Runs are deterministic (Tesseract is; the results are placed by crop
30//! index), so pinned outputs stay stable.
31
32use std::io::Write;
33use std::process::{Command, Stdio};
34
35use image::{imageops, RgbImage};
36
37use crate::layout::Region;
38use crate::ocr_prep::is_text_label;
39use crate::pdfium_backend::TextCell;
40use docling_core::debug_log;
41
42/// How the `tesseract` binary is invoked.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TesseractOptions {
45    /// Command or path of the executable (`DOCLING_TESSERACT`, default
46    /// `tesseract`).
47    pub cmd: String,
48    /// The `-l` argument — tessdata stems joined with `+`, already mapped by
49    /// [`lang_arg`]. `None` runs Tesseract's own default (`eng`).
50    pub lang: Option<String>,
51    /// Page segmentation mode (`--psm`, `DOCLING_RS_TESSERACT_PSM`, 0–13).
52    /// `None` keeps Tesseract's default (3, fully automatic) — the right
53    /// choice for layout-region crops; 6 (one uniform block) or 11 (sparse
54    /// text) help on odd inputs.
55    pub psm: Option<u8>,
56    /// `--tessdata-dir` (`DOCLING_RS_TESSDATA_DIR`); `None` leaves the lookup
57    /// to Tesseract (`TESSDATA_PREFIX`, then its build-time default).
58    pub tessdata_dir: Option<String>,
59}
60
61impl TesseractOptions {
62    /// The process-level options: `lang` from the caller (the mapped
63    /// `ocr_lang`), else `DOCLING_RS_OCR_LANG` read as Tesseract's language
64    /// list (#889: `DOCLING_RS_OCR_LANG=rus+eng` in a container's
65    /// environment never reached Tesseract, which ran its `eng` default; an
66    /// invalid value warns and leaves that default), the rest from the
67    /// environment. A `DOCLING_RS_TESSERACT_PSM` outside 0–13 warns and is
68    /// ignored.
69    pub fn from_env(lang: Option<String>) -> Self {
70        let lang = lang
71            .or_else(|| env_lang(docling_core::env::nonempty("DOCLING_RS_OCR_LANG").as_deref()));
72        let psm = docling_core::env::nonempty("DOCLING_RS_TESSERACT_PSM").and_then(|raw| {
73            let psm = parse_psm(&raw);
74            if psm.is_none() {
75                eprintln!(
76                    "docling-pdf: DOCLING_RS_TESSERACT_PSM={raw:?} is not a page \
77                         segmentation mode 0-13; using Tesseract's default"
78                );
79            }
80            psm
81        });
82        Self {
83            cmd: docling_core::env::nonempty("DOCLING_TESSERACT")
84                .unwrap_or_else(|| "tesseract".to_string()),
85            lang,
86            psm,
87            tessdata_dir: docling_core::env::nonempty("DOCLING_RS_TESSDATA_DIR"),
88        }
89    }
90}
91
92/// Tesseract's `-l` argument from a `DOCLING_RS_OCR_LANG` value (#889);
93/// `None` when unset, or invalid (which warns).
94fn env_lang(raw: Option<&str>) -> Option<String> {
95    lang_arg(raw?)
96        .map_err(|e| eprintln!("docling-pdf: DOCLING_RS_OCR_LANG: {e}; using Tesseract's default"))
97        .ok()
98}
99
100/// A `DOCLING_RS_TESSERACT_PSM` value: a page segmentation mode 0–13, or
101/// `None` for anything else.
102fn parse_psm(raw: &str) -> Option<u8> {
103    raw.trim().parse::<u8>().ok().filter(|&n| n <= 13)
104}
105
106/// Tesseract's `-l` argument for an `ocr_lang` value under this engine.
107///
108/// `+`-separated parts, each either a tessdata stem handed over verbatim —
109/// `eng`, `chi_sim`, `srp_latn`, `script/Cyrillic`, a traineddata file of your
110/// own (`[A-Za-z0-9_/][A-Za-z0-9_/-]*`, the identifier grammar docling's CLI
111/// model sanitizes with) — or a BCP-47 tag mapped onto the stem Tesseract
112/// names it by (its vocabulary is ISO 639-2/T: `de` → `deu`, `fr` → `fra`,
113/// `zh-Hans` → `chi_sim`, `zh-TW` → `chi_tra`, `sr-Latn` → `srp_latn`).
114/// A part is a tag when its primary subtag is two letters (docling's `iso:`
115/// prefix is accepted and optional, as it is for the PP-OCR engine, #388);
116/// region subtags are dropped, script subtags pick the variant. The PP-OCR
117/// codes map too (`en` → `eng`, `ch` → `chi_sim`, docling's legacy
118/// `english`/`chinese` and EasyOCR's `ch_sim`/`ch_tra`), so switching the
119/// engine never invalidates an `ocr_lang` that worked. Multiple languages
120/// are Tesseract's preference order. Unknown two-letter tags and malformed
121/// stems are errors (the message says what to write instead); whether a stem
122/// is *installed* is checked when the engine loads.
123pub fn lang_arg(raw: &str) -> Result<String, String> {
124    let mut stems = Vec::new();
125    for part in raw.split('+') {
126        let part = part.trim();
127        let part = part
128            .strip_prefix("iso:")
129            .or_else(|| part.strip_prefix("ISO:"))
130            .map_or(part, |tag| tag.trim());
131        if part.is_empty() {
132            return Err(format!("ocr_lang {raw:?} has an empty language entry"));
133        }
134        let mut subtags = part.split(['-', '_']);
135        let primary = subtags.next().unwrap_or_default();
136        let stem = if primary.len() == 2 && primary.bytes().all(|b| b.is_ascii_alphabetic()) {
137            let rest: Vec<String> = subtags.map(str::to_ascii_lowercase).collect();
138            bcp47_stem(&primary.to_ascii_lowercase(), &rest).ok_or_else(|| {
139                format!(
140                    "ocr_lang {part:?}: no Tesseract traineddata name is known for that \
141                     BCP-47 tag; give the tessdata stem directly (e.g. `deu`, `chi_sim`, \
142                     `script/Latin` — see `tesseract --list-langs`)"
143                )
144            })?
145        } else {
146            match part.to_ascii_lowercase().as_str() {
147                "english" => "eng".to_string(),
148                "chinese" => "chi_sim".to_string(),
149                "chinese_cht" => "chi_tra".to_string(),
150                _ => {
151                    let mut chars = part.chars();
152                    let head_ok = chars
153                        .next()
154                        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/');
155                    let tail_ok =
156                        chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '-'));
157                    if !(head_ok && tail_ok) {
158                        return Err(format!(
159                            "ocr_lang {part:?} is not a Tesseract language identifier \
160                             (letters, digits, `_`, `/`, `-`; e.g. `eng`, `deu+fra`, \
161                             `script/Cyrillic`)"
162                        ));
163                    }
164                    part.to_string()
165                }
166            }
167        };
168        if !stems.contains(&stem) {
169            stems.push(stem);
170        }
171    }
172    Ok(stems.join("+"))
173}
174
175/// The tessdata stem for a two-letter primary subtag plus its remaining
176/// subtags (lower-cased). Tesseract's names are ISO 639-2/T with a handful of
177/// deviations (docling's `_TESSERACT_CANONICAL_TO_CODE_DEVIATIONS`); the
178/// table covers the languages with a two-letter code among the traineddata
179/// files Tesseract ships.
180fn bcp47_stem(primary: &str, subtags: &[String]) -> Option<String> {
181    let has = |s: &str| subtags.iter().any(|t| t == s);
182    let stem = match primary {
183        // Script-dependent stems.
184        "zh" | "ch" => {
185            if has("hant") || has("tw") || has("hk") || has("mo") || has("tra") {
186                "chi_tra"
187            } else {
188                "chi_sim"
189            }
190        }
191        "sr" => {
192            if has("latn") {
193                "srp_latn"
194            } else {
195                "srp"
196            }
197        }
198        "az" => {
199            if has("cyrl") {
200                "aze_cyrl"
201            } else {
202                "aze"
203            }
204        }
205        "uz" => {
206            if has("cyrl") {
207                "uzb_cyrl"
208            } else {
209                "uzb"
210            }
211        }
212        "de" => {
213            if has("latf") {
214                "deu_latf"
215            } else {
216                "deu"
217            }
218        }
219        "ku" => "kmr",
220        "no" | "nb" | "nn" => "nor",
221        "af" => "afr",
222        "am" => "amh",
223        "ar" => "ara",
224        "as" => "asm",
225        "be" => "bel",
226        "bn" => "ben",
227        "bo" => "bod",
228        "bs" => "bos",
229        "br" => "bre",
230        "bg" => "bul",
231        "ca" => "cat",
232        "cs" => "ces",
233        "co" => "cos",
234        "cy" => "cym",
235        "da" => "dan",
236        "dv" => "div",
237        "dz" => "dzo",
238        "el" => "ell",
239        "en" => "eng",
240        "eo" => "epo",
241        "et" => "est",
242        "eu" => "eus",
243        "fo" => "fao",
244        "fa" => "fas",
245        "fi" => "fin",
246        "fr" => "fra",
247        "fy" => "fry",
248        "gd" => "gla",
249        "ga" => "gle",
250        "gl" => "glg",
251        "gu" => "guj",
252        "ht" => "hat",
253        "he" => "heb",
254        "hi" => "hin",
255        "hr" => "hrv",
256        "hu" => "hun",
257        "hy" => "hye",
258        "iu" => "iku",
259        "id" => "ind",
260        "is" => "isl",
261        "it" => "ita",
262        "jv" => "jav",
263        "ja" => "jpn",
264        "kn" => "kan",
265        "ka" => "kat",
266        "kk" => "kaz",
267        "km" => "khm",
268        "ky" => "kir",
269        "ko" => "kor",
270        "lo" => "lao",
271        "la" => "lat",
272        "lv" => "lav",
273        "lt" => "lit",
274        "lb" => "ltz",
275        "ml" => "mal",
276        "mr" => "mar",
277        "mk" => "mkd",
278        "mt" => "mlt",
279        "mn" => "mon",
280        "mi" => "mri",
281        "ms" => "msa",
282        "my" => "mya",
283        "ne" => "nep",
284        "nl" => "nld",
285        "oc" => "oci",
286        "or" => "ori",
287        "pa" => "pan",
288        "pl" => "pol",
289        "pt" => "por",
290        "ps" => "pus",
291        "qu" => "que",
292        "ro" => "ron",
293        "ru" => "rus",
294        "sa" => "san",
295        "si" => "sin",
296        "sk" => "slk",
297        "sl" => "slv",
298        "sd" => "snd",
299        "es" => "spa",
300        "sq" => "sqi",
301        "su" => "sun",
302        "sw" => "swa",
303        "sv" => "swe",
304        "ta" => "tam",
305        "tt" => "tat",
306        "te" => "tel",
307        "tg" => "tgk",
308        "th" => "tha",
309        "ti" => "tir",
310        "to" => "ton",
311        "tr" => "tur",
312        "ug" => "uig",
313        "uk" => "ukr",
314        "ur" => "urd",
315        "vi" => "vie",
316        "yi" => "yid",
317        "yo" => "yor",
318        _ => return None,
319    };
320    Some(stem.to_string())
321}
322
323/// One `tsv` word row (level 5): its position in Tesseract's block /
324/// paragraph / line hierarchy, its box in crop pixels and its confidence
325/// (0–100).
326#[derive(Debug, Clone, PartialEq)]
327struct Word {
328    block: u32,
329    par: u32,
330    line: u32,
331    l: f32,
332    t: f32,
333    r: f32,
334    b: f32,
335    conf: f32,
336    text: String,
337}
338
339/// Parse Tesseract's `tsv` output: the header names the columns (`level
340/// page_num block_num par_num line_num word_num left top width height conf
341/// text`); only level-5 rows with non-blank text are words — the block /
342/// paragraph / line rows carry no text and `conf -1`.
343fn parse_tsv(tsv: &str) -> Vec<Word> {
344    let mut lines = tsv.lines();
345    let Some(header) = lines.next() else {
346        return Vec::new();
347    };
348    let cols: Vec<&str> = header.split('\t').collect();
349    let col = |name: &str| cols.iter().position(|c| *c == name);
350    let (
351        Some(level),
352        Some(block),
353        Some(par),
354        Some(line),
355        Some(left),
356        Some(top),
357        Some(w),
358        Some(h),
359        Some(conf),
360        Some(text),
361    ) = (
362        col("level"),
363        col("block_num"),
364        col("par_num"),
365        col("line_num"),
366        col("left"),
367        col("top"),
368        col("width"),
369        col("height"),
370        col("conf"),
371        col("text"),
372    )
373    else {
374        return Vec::new();
375    };
376    let mut words = Vec::new();
377    for row in lines {
378        let f: Vec<&str> = row.split('\t').collect();
379        if f.len() <= text || f[level] != "5" {
380            continue;
381        }
382        let text = f[text].trim();
383        if text.is_empty() {
384            continue;
385        }
386        let num = |i: usize| f[i].trim().parse::<f32>().unwrap_or(0.0);
387        let idx = |i: usize| f[i].trim().parse::<u32>().unwrap_or(0);
388        let (l, t) = (num(left), num(top));
389        words.push(Word {
390            block: idx(block),
391            par: idx(par),
392            line: idx(line),
393            l,
394            t,
395            r: l + num(w),
396            b: t + num(h),
397            conf: num(conf).clamp(0.0, 100.0),
398            text: text.to_string(),
399        });
400    }
401    words
402}
403
404/// A recognized text unit in crop pixels: `(l, t, r, b, text, confidence)`,
405/// confidence on the 0–1 scale the page `ocr_score` expects.
406type Unit = (f32, f32, f32, f32, String, f32);
407
408/// Words → lines: consecutive words with the same block / paragraph / line
409/// index join with single spaces (the order `tsv` lists them in is reading
410/// order within the line), the box is their union and the confidence the
411/// mean of theirs.
412fn group_lines(words: &[Word]) -> Vec<Unit> {
413    let mut out: Vec<Unit> = Vec::new();
414    let mut key: Option<(u32, u32, u32)> = None;
415    let mut n = 0.0f32;
416    for w in words {
417        let k = (w.block, w.par, w.line);
418        if key == Some(k) {
419            let last = out.last_mut().expect("a line is open");
420            last.0 = last.0.min(w.l);
421            last.1 = last.1.min(w.t);
422            last.2 = last.2.max(w.r);
423            last.3 = last.3.max(w.b);
424            last.4.push(' ');
425            last.4.push_str(&w.text);
426            last.5 = (last.5 * n + w.conf / 100.0) / (n + 1.0);
427            n += 1.0;
428        } else {
429            key = Some(k);
430            n = 1.0;
431            out.push((w.l, w.t, w.r, w.b, w.text.clone(), w.conf / 100.0));
432        }
433    }
434    out
435}
436
437/// Each word on its own, for the table cell matcher.
438fn word_units(words: &[Word]) -> Vec<Unit> {
439    words
440        .iter()
441        .map(|w| (w.l, w.t, w.r, w.b, w.text.clone(), w.conf / 100.0))
442        .collect()
443}
444
445/// Minimum pixels of white paper added around a region crop: Tesseract's
446/// page segmentation wants text clear of the image border, and a layout box
447/// often sits on the ink.
448const CROP_PAD: u32 = 6;
449/// The pad also grows with the region ([`crop_pad`]): a layout box hugs the
450/// x-height/baseline band, and a diacritic — tilde, acute, cedilla — sits
451/// outside it by a fraction of the glyph size. At the PDF path's 2.0 px/pt
452/// render 6 px is a quarter of a 12 pt line and clears them; a 300 dpi PNG
453/// converts at scale 1.0 with 48 px glyphs, where the same 6 px clipped the
454/// tilde and the cedilla and Tesseract read "Acao" for "Ação" (found with
455/// #471's synthetic Portuguese scan). A quarter of the line (12 px) still
456/// landed on the cedilla's edge — its ink runs 12 px under that layout box —
457/// so 0.3 (14 px); the 12 pt PDF case goes from 6 to 7 px of white paper.
458const CROP_PAD_RATIO: f32 = 0.3;
459/// Cap on the grown pad, so a tall multi-line block does not drag whole
460/// neighbouring lines into its crop — and whatever partial neighbour the pad
461/// still admits is dropped by [`unit_in_region`], never a cell.
462const CROP_PAD_MAX: u32 = 48;
463
464/// The pad in image pixels for a region `h_px` tall: `CROP_PAD_RATIO` of the
465/// height, no less than [`CROP_PAD`], no more than [`CROP_PAD_MAX`].
466fn crop_pad(h_px: f32) -> u32 {
467    // `max` / `min` (not `clamp`) so a NaN height degrades to the minimum.
468    (h_px * CROP_PAD_RATIO)
469        .round()
470        .max(CROP_PAD as f32)
471        .min(CROP_PAD_MAX as f32) as u32
472}
473
474/// One region's crop for Tesseract.
475struct Crop {
476    /// The crop's origin in image pixels (its top-left corner on the page).
477    ox: u32,
478    oy: u32,
479    png: Vec<u8>,
480    /// The unpadded region box in image pixels, `(l, t, r, b)`: only units
481    /// centred inside it (plus [`CROP_PAD`] of slack for layout-box jitter)
482    /// count as this region's text — the pad exists to keep glyph extremities
483    /// whole, not to read the neighbours it lets in.
484    bounds: (f32, f32, f32, f32),
485}
486
487/// A region's padded crop as PNG bytes.
488fn crop_png(img: &RgbImage, region: &Region, scale: f32) -> Option<Crop> {
489    let (iw, ih) = img.dimensions();
490    let bounds = (
491        region.l * scale,
492        region.t * scale,
493        region.r * scale,
494        region.b * scale,
495    );
496    let pad = crop_pad(bounds.3 - bounds.1);
497    let l = (bounds.0.max(0.0) as u32).saturating_sub(pad);
498    let t = (bounds.1.max(0.0) as u32).saturating_sub(pad);
499    let r = ((bounds.2.max(0.0) as u32).saturating_add(pad)).min(iw);
500    let b = ((bounds.3.max(0.0) as u32).saturating_add(pad)).min(ih);
501    if r <= l || b <= t {
502        return None;
503    }
504    let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
505    Some(Crop {
506        ox: l,
507        oy: t,
508        png: encode_png(&crop)?,
509        bounds,
510    })
511}
512
513/// Whether a recognized unit (crop-relative px box) belongs to the crop's
514/// region: its centre lies inside the region box, with [`CROP_PAD`] of slack
515/// on every side. A neighbouring line the pad pulled into the crop has its
516/// centre outside and is discarded; a line the layout box merely sits a few
517/// pixels off from stays.
518fn unit_in_region(crop: &Crop, unit: &Unit) -> bool {
519    let (l, t, r, b, _, _) = unit;
520    let cx = crop.ox as f32 + (l + r) / 2.0;
521    let cy = crop.oy as f32 + (t + b) / 2.0;
522    let slack = CROP_PAD as f32;
523    let (bl, bt, br, bb) = crop.bounds;
524    cx >= bl - slack && cx <= br + slack && cy >= bt - slack && cy <= bb + slack
525}
526
527fn encode_png(img: &RgbImage) -> Option<Vec<u8>> {
528    let mut buf = std::io::Cursor::new(Vec::new());
529    img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
530    Some(buf.into_inner())
531}
532
533/// The loaded engine: the validated options plus what `--list-langs` said.
534pub struct TesseractOcr {
535    opts: TesseractOptions,
536    /// Crops recognized concurrently (one child process each).
537    lanes: usize,
538    /// Whether the `osd` traineddata is installed — orientation detection
539    /// needs it.
540    has_osd: bool,
541}
542
543impl TesseractOcr {
544    /// Probe the binary (`--version`) and its languages (`--list-langs`),
545    /// checking every requested stem is installed. An error here is what the
546    /// pipeline shows as "OCR unavailable" (degrading like a missing model,
547    /// #244) — it names the binary, the missing stems and what is installed.
548    pub fn load(opts: TesseractOptions, lanes: usize) -> Result<Self, String> {
549        let lanes = docling_core::env::parse::<usize>("DOCLING_RS_OCR_SESSIONS")
550            .filter(|&n| n > 0)
551            .unwrap_or(lanes)
552            .clamp(1, 16);
553        let version = Command::new(&opts.cmd)
554            .arg("--version")
555            .stdin(Stdio::null())
556            .output()
557            .map_err(|e| {
558                format!(
559                    "tesseract binary {:?} not runnable ({e}); install tesseract-ocr or point \
560                     DOCLING_TESSERACT at it",
561                    opts.cmd
562                )
563            })?;
564        // Linux prints the version on stdout, older Windows builds on stderr.
565        let banner = [version.stdout, version.stderr]
566            .iter()
567            .map(|b| String::from_utf8_lossy(b).trim().to_string())
568            .find(|s| !s.is_empty())
569            .unwrap_or_default();
570        let banner = banner.lines().next().unwrap_or_default().to_string();
571        let mut list = Command::new(&opts.cmd);
572        list.arg("--list-langs").stdin(Stdio::null());
573        if let Some(dir) = &opts.tessdata_dir {
574            list.arg("--tessdata-dir").arg(dir);
575        }
576        let list = list
577            .output()
578            .map_err(|e| format!("tesseract --list-langs failed to run: {e}"))?;
579        // The header line ("List of available languages in ... (N):") is
580        // followed by one stem per line; Windows spells script packs with a
581        // backslash, `-l` wants the slash everywhere.
582        let installed: Vec<String> = String::from_utf8_lossy(&list.stdout)
583            .lines()
584            .skip(1)
585            .map(|l| l.trim().replace('\\', "/"))
586            .filter(|l| !l.is_empty())
587            .collect();
588        if installed.is_empty() {
589            return Err(format!(
590                "{banner}: no traineddata found (tesseract --list-langs printed nothing); \
591                 install a language pack (e.g. tesseract-ocr-eng) or set \
592                 DOCLING_RS_TESSDATA_DIR / TESSDATA_PREFIX"
593            ));
594        }
595        let wanted: Vec<&str> = match &opts.lang {
596            Some(l) => l.split('+').collect(),
597            None => vec!["eng"],
598        };
599        let missing: Vec<&str> = wanted
600            .iter()
601            .copied()
602            .filter(|w| !installed.iter().any(|i| i == w))
603            .collect();
604        if !missing.is_empty() {
605            return Err(format!(
606                "{banner}: no traineddata for {} (installed: {}); install the language \
607                 pack or pick an installed one with ocr_lang",
608                missing.join(", "),
609                installed.join(", ")
610            ));
611        }
612        let has_osd = installed.iter().any(|i| i == "osd");
613        debug_log!(
614            "docling-pdf: OCR engine {banner} (lang {}, psm {}, {} lane(s), osd {})",
615            opts.lang.as_deref().unwrap_or("eng"),
616            opts.psm.map_or("default".to_string(), |p| p.to_string()),
617            lanes,
618            if has_osd { "yes" } else { "no" }
619        );
620        Ok(Self {
621            opts,
622            lanes,
623            has_osd,
624        })
625    }
626
627    /// A child process with the common arguments: the binary, the language,
628    /// the data directory, and one OpenMP thread (the lanes own the
629    /// parallelism; libgomp's default of one thread per core per process
630    /// would oversubscribe every core `lanes` times — and Tesseract is not
631    /// faster multi-threaded on a small crop anyway).
632    fn command(&self) -> Command {
633        let mut cmd = Command::new(&self.opts.cmd);
634        cmd.env("OMP_THREAD_LIMIT", "1")
635            .stdin(Stdio::piped())
636            .stdout(Stdio::piped())
637            .stderr(Stdio::null());
638        if let Some(dir) = &self.opts.tessdata_dir {
639            cmd.arg("--tessdata-dir").arg(dir);
640        }
641        cmd
642    }
643
644    /// Feed `png` to a child on stdin and return its stdout. A child that
645    /// fails (a crop Tesseract rejects) is an `Err` the caller degrades
646    /// per crop.
647    fn run(&self, mut cmd: Command, png: &[u8]) -> Result<String, String> {
648        let mut child = cmd
649            .spawn()
650            .map_err(|e| format!("tesseract: spawn {:?}: {e}", self.opts.cmd))?;
651        // Write on a thread: a crop bigger than the pipe buffer would
652        // otherwise deadlock against a child that has started printing.
653        let mut stdin = child.stdin.take().expect("piped stdin");
654        let png = png.to_vec();
655        let writer = std::thread::spawn(move || stdin.write_all(&png));
656        let out = child
657            .wait_with_output()
658            .map_err(|e| format!("tesseract: wait: {e}"))?;
659        let _ = writer.join();
660        if !out.status.success() {
661            return Err(format!("tesseract exited with {}", out.status));
662        }
663        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
664    }
665
666    /// Recognize one crop: `tesseract stdin stdout [-l L] [--psm N] --dpi D tsv`.
667    fn recognize(&self, png: &[u8], dpi: u32) -> Result<Vec<Word>, String> {
668        let mut cmd = self.command();
669        if let Some(lang) = &self.opts.lang {
670            cmd.arg("-l").arg(lang);
671        }
672        if let Some(psm) = self.opts.psm {
673            cmd.arg("--psm").arg(psm.to_string());
674        }
675        cmd.arg("--dpi").arg(dpi.to_string());
676        cmd.args(["stdin", "stdout", "tsv"]);
677        Ok(parse_tsv(&self.run(cmd, png)?))
678    }
679
680    /// Recognize every crop of `jobs` across the lanes, and return each
681    /// crop's units mapped to page points (`origin + px` / `scale`), in job
682    /// order; units centred outside the crop's region ([`unit_in_region`])
683    /// are dropped. A crop Tesseract fails on contributes nothing (logged
684    /// under `DOCLING_RS_DEBUG`), the rest of the page still reads out.
685    fn recognize_all(
686        &self,
687        jobs: &[Crop],
688        scale: f32,
689        units: fn(&[Word]) -> Vec<Unit>,
690    ) -> Vec<(TextCell, f32)> {
691        let dpi = (scale * 72.0).round().max(1.0) as u32;
692        let lanes = self.lanes.min(jobs.len()).max(1);
693        let next = std::sync::atomic::AtomicUsize::new(0);
694        let results: Vec<std::sync::Mutex<Option<Vec<Word>>>> =
695            jobs.iter().map(|_| std::sync::Mutex::new(None)).collect();
696        std::thread::scope(|s| {
697            for _ in 0..lanes {
698                s.spawn(|| loop {
699                    let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
700                    let Some(job) = jobs.get(i) else {
701                        break;
702                    };
703                    let words = match self.recognize(&job.png, dpi) {
704                        Ok(words) => words,
705                        Err(e) => {
706                            debug_log!("docling-pdf: tesseract: crop {i}: {e}; no text");
707                            Vec::new()
708                        }
709                    };
710                    *results[i].lock().expect("crop result slot") = Some(words);
711                });
712            }
713        });
714        let mut cells = Vec::new();
715        for (job, slot) in jobs.iter().zip(results) {
716            let words = slot
717                .into_inner()
718                .expect("crop result slot")
719                .unwrap_or_default();
720            let (ox, oy) = (job.ox as f32, job.oy as f32);
721            for unit in units(&words) {
722                if !unit_in_region(job, &unit) {
723                    continue;
724                }
725                let (l, t, r, b, text, conf) = unit;
726                let text = text.trim().to_string();
727                if text.is_empty() {
728                    continue;
729                }
730                cells.push((
731                    TextCell {
732                        text,
733                        l: (ox + l) / scale,
734                        t: (oy + t) / scale,
735                        r: (ox + r) / scale,
736                        b: (oy + b) / scale,
737                    },
738                    conf,
739                ));
740            }
741        }
742        cells
743    }
744
745    /// The crops of the regions `keep` selects.
746    fn crops(img: &RgbImage, regions: &[Region], scale: f32, keep: fn(&str) -> bool) -> Vec<Crop> {
747        regions
748            .iter()
749            .filter(|r| keep(r.label))
750            .filter_map(|r| crop_png(img, r, scale))
751            .collect()
752    }
753
754    /// OCR a page's text regions into line cells (page points) with their
755    /// confidence — the counterpart of [`crate::ocr::OcrModel::ocr_page`].
756    /// `scale` is image px per page point.
757    pub fn ocr_page(
758        &mut self,
759        img: &RgbImage,
760        regions: &[Region],
761        scale: f32,
762    ) -> Result<Vec<(TextCell, f32)>, String> {
763        let jobs = crate::timing::timed("ocr.prep", || {
764            Self::crops(img, regions, scale, is_text_label)
765        });
766        Ok(crate::timing::timed("ocr.rec", || {
767            self.recognize_all(&jobs, scale, group_lines)
768        }))
769    }
770
771    /// Word cells inside the page's table regions, for the cell matcher — the
772    /// counterpart of [`crate::ocr::OcrModel::ocr_table_words`].
773    pub fn ocr_table_words(
774        &mut self,
775        img: &RgbImage,
776        regions: &[Region],
777        scale: f32,
778    ) -> Result<Vec<(TextCell, f32)>, String> {
779        let jobs = Self::crops(img, regions, scale, crate::assemble::is_table_like);
780        Ok(self.recognize_all(&jobs, scale, word_units))
781    }
782
783    /// The clockwise angle the page content is rotated by in `img`
784    /// (`0`/`90`/`180`/`270`, the [`crate::orient::detect`] convention), from
785    /// Tesseract's orientation-and-script detection (`--psm 0 -l osd`).
786    /// `None` when the `osd` traineddata is not installed or OSD cannot make
787    /// a call (too little text) — the page then stays as rendered.
788    pub fn detect_orientation(&self, img: &RgbImage, scale: f32) -> Option<u16> {
789        if !self.has_osd {
790            debug_log!("docling-pdf: tesseract: no `osd` traineddata; orientation not probed");
791            return None;
792        }
793        let png = encode_png(img)?;
794        let mut cmd = self.command();
795        cmd.args(["--psm", "0", "-l", "osd", "--dpi"])
796            .arg(((scale * 72.0).round().max(1.0) as u32).to_string())
797            .args(["stdin", "stdout"]);
798        match self.run(cmd, &png) {
799            Ok(out) => parse_osd(&out),
800            Err(e) => {
801                debug_log!("docling-pdf: tesseract OSD failed ({e}); assuming upright");
802                None
803            }
804        }
805    }
806}
807
808/// The `Orientation in degrees:` line of an OSD report — Tesseract's clockwise
809/// angle of the text, the same convention as the probe's `deg` (its `Rotate:`
810/// line is the complementary correction).
811fn parse_osd(out: &str) -> Option<u16> {
812    let deg = out
813        .lines()
814        .find_map(|l| l.trim().strip_prefix("Orientation in degrees:"))?
815        .trim()
816        .parse::<u16>()
817        .ok()?;
818    matches!(deg, 0 | 90 | 180 | 270).then_some(deg)
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    /// `ocr_lang` under the Tesseract engine: stems verbatim, BCP-47 tags and
826    /// the PP-OCR codes mapped, lists joined in order, junk rejected.
827    /// #889: `DOCLING_RS_OCR_LANG` reaches Tesseract when the request
828    /// names no language — `rus+eng` as written, a tag mapped — and an
829    /// invalid value leaves Tesseract's default.
830    #[test]
831    fn env_lang_is_tesseracts_language_list() {
832        assert_eq!(env_lang(Some("rus+eng")).as_deref(), Some("rus+eng"));
833        assert_eq!(env_lang(Some("ru+en")).as_deref(), Some("rus+eng"));
834        assert_eq!(env_lang(Some("de; rm -rf")), None);
835        assert_eq!(env_lang(None), None);
836    }
837
838    #[test]
839    fn lang_arg_maps_tags_and_keeps_stems() {
840        for (raw, want) in [
841            ("eng", "eng"),
842            ("en", "eng"),
843            ("EN-us", "eng"),
844            ("iso:en", "eng"),
845            ("english", "eng"),
846            ("ch", "chi_sim"),
847            ("ch_tra", "chi_tra"),
848            ("chinese_cht", "chi_tra"),
849            ("zh", "chi_sim"),
850            ("zh-Hant", "chi_tra"),
851            ("zh-TW", "chi_tra"),
852            ("iso:zh-CN", "chi_sim"),
853            ("de", "deu"),
854            ("de-Latf", "deu_latf"),
855            ("sr-Latn", "srp_latn"),
856            ("sr", "srp"),
857            ("nb", "nor"),
858            ("deu+fra", "deu+fra"),
859            ("iso:de + fr", "deu+fra"),
860            ("eng+eng", "eng"),
861            ("script/Cyrillic", "script/Cyrillic"),
862            ("chi_sim", "chi_sim"),
863            ("srp_latn", "srp_latn"),
864            ("custom_model", "custom_model"),
865        ] {
866            assert_eq!(lang_arg(raw).as_deref(), Ok(want), "{raw:?}");
867        }
868        for raw in ["", "xx", "en+", "de;u", "a b", "iso:", "deu fra"] {
869            assert!(lang_arg(raw).is_err(), "{raw:?} should be rejected");
870        }
871    }
872
873    /// The crop pad follows the glyph size: the 6 px floor for a 10 pt line
874    /// at the PDF path's 2.0 px/pt render, 14 px for a 48 px line of a
875    /// 300 dpi image at scale 1.0 — where 6 px clipped the tilde and the
876    /// cedilla and 12 px still grazed the cedilla — and capped for a tall
877    /// block.
878    #[test]
879    fn crop_pad_grows_with_the_region_and_is_capped() {
880        assert_eq!(crop_pad(20.0), CROP_PAD);
881        assert_eq!(crop_pad(48.0), 14);
882        assert_eq!(crop_pad(1000.0), CROP_PAD_MAX);
883        assert_eq!(crop_pad(f32::NAN), CROP_PAD);
884        let region = Region {
885            label: "text",
886            score: 0.9,
887            l: 100.0,
888            t: 100.0,
889            r: 300.0,
890            b: 148.0,
891        };
892        let img = RgbImage::from_pixel(400, 400, image::Rgb([255, 255, 255]));
893        let crop = crop_png(&img, &region, 1.0).expect("crop");
894        assert_eq!((crop.ox, crop.oy), (86, 86), "14 px pad on a 48 px line");
895        assert_eq!(crop.bounds, (100.0, 100.0, 300.0, 148.0));
896        let crop = crop_png(&img, &region, 0.4).expect("crop");
897        assert_eq!((crop.ox, crop.oy), (34, 34), "6 px floor on a 19 px line");
898    }
899
900    /// A neighbouring line the grown pad admits into the crop is not this
901    /// region's cell; a line whose layout box sits a few pixels off still is.
902    #[test]
903    fn units_outside_the_region_are_dropped() {
904        let crop = Crop {
905            ox: 86,
906            oy: 86,
907            png: Vec::new(),
908            bounds: (100.0, 100.0, 300.0, 148.0),
909        };
910        // Crop-relative: the region's own line spans y 14..62 inside the crop.
911        let own: Unit = (14.0, 14.0, 214.0, 62.0, "Ação".into(), 0.9);
912        // Bleeding 4 px past the box top (layout jitter) — still inside.
913        let jittered: Unit = (14.0, 6.0, 214.0, 58.0, "Ação".into(), 0.9);
914        // The next line down, starting where the region ends: centre outside.
915        let neighbour: Unit = (14.0, 62.0, 214.0, 110.0, "próxima".into(), 0.9);
916        assert!(unit_in_region(&crop, &own));
917        assert!(unit_in_region(&crop, &jittered));
918        assert!(!unit_in_region(&crop, &neighbour));
919    }
920
921    /// The `tsv` shape Tesseract 5 prints: only level-5 rows are words; a
922    /// block's two lines become two cells, words joined by single spaces,
923    /// boxes unioned, confidence averaged onto 0–1; table mode keeps words.
924    #[test]
925    fn tsv_words_group_into_lines() {
926        let tsv = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n\
927                   1\t1\t0\t0\t0\t0\t0\t0\t600\t160\t-1\t\n\
928                   4\t1\t1\t1\t1\t0\t23\t28\t242\t25\t-1\t\n\
929                   5\t1\t1\t1\t1\t1\t23\t28\t64\t20\t93.2\tHello\n\
930                   5\t1\t1\t1\t1\t2\t94\t28\t93\t25\t92.4\tdocling\n\
931                   5\t1\t1\t1\t1\t3\t195\t28\t70\t20\t96.0\tworld\n\
932                   5\t1\t2\t1\t1\t1\t21\t98\t94\t20\t96.8\tSecond\n\
933                   5\t1\t2\t1\t1\t2\t125\t98\t44\t20\t50\t \n\
934                   5\t1\t2\t1\t1\t3\t179\t99\t44\t19\t96.8\t123\n";
935        let words = parse_tsv(tsv);
936        assert_eq!(words.len(), 5);
937        let lines = group_lines(&words);
938        assert_eq!(lines.len(), 2);
939        let (l, t, r, b, text, conf) = &lines[0];
940        assert_eq!(text, "Hello docling world");
941        assert_eq!((*l, *t, *r, *b), (23.0, 28.0, 265.0, 53.0));
942        assert!((conf - 0.9387).abs() < 1e-3, "{conf}");
943        assert_eq!(lines[1].4, "Second 123");
944        assert_eq!(word_units(&words).len(), 5);
945        assert!(parse_tsv("").is_empty());
946        assert!(parse_tsv("garbage\n5\t1\n").is_empty());
947    }
948
949    #[test]
950    fn osd_orientation_parses() {
951        let out = "Page number: 0\nOrientation in degrees: 270\nRotate: 90\n\
952                   Orientation confidence: 6.47\nScript: Latin\nScript confidence: 4.05\n";
953        assert_eq!(parse_osd(out), Some(270));
954        assert_eq!(parse_osd("Orientation in degrees: 0\n"), Some(0));
955        assert_eq!(parse_osd("Orientation in degrees: 45\n"), None);
956        assert_eq!(parse_osd("Too few characters. Skipping this page\n"), None);
957    }
958
959    #[test]
960    fn psm_is_range_checked() {
961        assert_eq!(parse_psm("6"), Some(6));
962        assert_eq!(parse_psm(" 13 "), Some(13));
963        assert_eq!(parse_psm("0"), Some(0));
964        for bad in ["14", "-1", "300", "six", ""] {
965            assert_eq!(parse_psm(bad), None, "{bad:?}");
966        }
967    }
968}