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