Skip to main content

docling_pdf/
ocr.rs

1//! OCR for scanned pages, via the PP-OCRv3 recognition model (CRNN/SVTR) run
2//! with `ort`. The layout model already locates text regions on the page image
3//! (it works without a text layer), so OCR only needs *recognition*: each text
4//! region is cropped, split into lines by horizontal projection, and each line
5//! is recognised and decoded with CTC — producing [`TextCell`]s the normal
6//! layout assembly then consumes. This avoids a separate text-detection model.
7
8use image::RgbImage;
9use ort::session::Session;
10use ort::value::Tensor;
11
12use crate::layout::Region;
13// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
14// so the wasm build shares it verbatim (#79 phase 2).
15use crate::ocr_prep::{
16    batch_input, decode_row, 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    rec: Session,
23    /// CTC classes: index 0 = blank, 1..=6623 = dictionary, 6624 = space.
24    chars: Vec<String>,
25}
26
27/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs.
28///
29/// The default is **English** (`models/ocr_rec_en.onnx` + `models/en_dict.txt`):
30/// the multilingual `ch_` model reads Latin scripts with badly degraded word
31/// spacing (glued words on ordinary English scans), which is the common
32/// real-world case. `Ch` selects the `ch_` pair (`models/ocr_rec.onnx` +
33/// `models/ppocr_keys_v1.txt`) — that is what upstream docling conformance is
34/// measured with, and `scripts/conformance/pdf_*.sh` pin it explicitly (by
35/// path, which wins over this selector).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum OcrLang {
38    /// en_PP-OCRv3 — English-only, proper Latin word spacing.
39    #[default]
40    En,
41    /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
42    Ch,
43}
44
45impl OcrLang {
46    /// Parse a user-supplied language id. `None` for anything but `en`/`ch`
47    /// (trimmed, case-insensitive) — callers surface their own error/warning.
48    pub fn parse(s: &str) -> Option<Self> {
49        match s.trim().to_ascii_lowercase().as_str() {
50            "en" => Some(Self::En),
51            "ch" => Some(Self::Ch),
52            _ => None,
53        }
54    }
55
56    /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
57    /// English default; unknown values warn and use English).
58    pub fn from_env() -> Self {
59        let raw = std::env::var("DOCLING_RS_OCR_LANG").unwrap_or_default();
60        if raw.trim().is_empty() {
61            return Self::default();
62        }
63        Self::parse(&raw).unwrap_or_else(|| {
64            eprintln!("docling-pdf: DOCLING_RS_OCR_LANG={raw:?} is not en|ch; using en");
65            Self::default()
66        })
67    }
68}
69
70/// Resolve the recognition model + dictionary pair for `lang`. An English
71/// default that isn't on disk (older model checkouts) degrades to the `ch_`
72/// pair with a warning rather than failing — the usual missing-optional-asset
73/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
74/// over all of this; they are a pair, so set both together.
75pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
76    const CH: (&str, &str) = ("models/ocr_rec.onnx", "models/ppocr_keys_v1.txt");
77    const EN: (&str, &str) = ("models/ocr_rec_en.onnx", "models/en_dict.txt");
78    let want_ch = lang == OcrLang::Ch;
79    let pick = if want_ch { CH } else { EN };
80    let (mut rec, mut dict) = (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1));
81    if !want_ch && (!std::path::Path::new(&rec).exists() || !std::path::Path::new(&dict).exists()) {
82        let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
83        if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
84            eprintln!(
85                "docling-pdf: English OCR model not found ({rec}); falling back to the \
86                 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
87                 scripts/install/download_dependencies.sh"
88            );
89            (rec, dict) = (ch_rec, ch_dict);
90        }
91    }
92    (
93        std::env::var("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
94        std::env::var("DOCLING_OCR_DICT").unwrap_or(dict),
95    )
96}
97
98impl OcrModel {
99    /// Load the recognition model and its character dictionary for `lang` —
100    /// see [`resolve_rec_pair`] for the selection rules (explicit
101    /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win).
102    pub fn load(lang: OcrLang) -> Result<Self, String> {
103        let (rec_path, dict_path) = resolve_rec_pair(lang);
104        // Single-threaded: ORT's multi-threaded float-reduction order varies
105        // across runs, which flips the CTC argmax on low-confidence characters
106        // (e.g. noisy faxes) and makes the snapshot output non-deterministic. The
107        // recognition inputs are tiny per-line crops, so the throughput cost is
108        // negligible.
109        let builder = Session::builder()
110            .map_err(|e| format!("ocr: builder: {e}"))?
111            .with_intra_threads(1)
112            .map_err(|e| format!("ocr: intra_threads: {e}"))?;
113        let rec = crate::ep::apply(builder)
114            .map_err(|e| format!("ocr: {e}"))?
115            .commit_from_file(&rec_path)
116            .map_err(|e| format!("ocr: load {rec_path}: {e}"))?;
117        let dict = std::fs::read_to_string(&dict_path)
118            .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
119        Ok(Self {
120            rec,
121            chars: dict_chars(&dict),
122        })
123    }
124
125    /// Recognise a batch of prepared *same-width* lines in one session run.
126    ///
127    /// Only equal widths ever share a run: same-width batching is
128    /// bit-identical to one-at-a-time recognition (each sample keeps its own
129    /// data and per-sample kernel reduction order — verified empirically on
130    /// the scanned corpus), whereas width-padding leaks into the real
131    /// timesteps through the model's global-attention blocks and measurably
132    /// changes low-confidence characters.
133    fn recognize_batch(
134        &mut self,
135        w: usize,
136        chunk: &[usize],
137        lines: &[PrepLine],
138    ) -> Result<Vec<String>, String> {
139        let n = chunk.len();
140        let data = batch_input(w, chunk, lines);
141        let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
142            .map_err(|e| format!("ocr: input tensor: {e}"))?;
143        let outputs = self
144            .rec
145            .run(ort::inputs!["x" => input])
146            .map_err(|e| format!("ocr: rec inference: {e}"))?;
147        let (shape, probs) = outputs[0]
148            .try_extract_tensor::<f32>()
149            .map_err(|e| format!("ocr: extract rec: {e}"))?;
150        let t_len = shape[1] as usize;
151        let nc = shape[2] as usize;
152        Ok((0..n)
153            .map(|i| {
154                decode_row(
155                    &self.chars,
156                    &probs[i * t_len * nc..(i + 1) * t_len * nc],
157                    nc,
158                )
159            })
160            .collect())
161    }
162
163    /// OCR a page: produce text cells (page points) for every line found inside
164    /// the text regions. `scale` is image-px per page-point.
165    pub fn ocr_page(
166        &mut self,
167        img: &RgbImage,
168        regions: &[Region],
169        scale: f32,
170    ) -> Result<Vec<TextCell>, String> {
171        // Gather every line crop on the page first (shared with the browser
172        // path), so equal-width lines can share a recognition run regardless
173        // of which region they came from.
174        let (bboxes, lines) = prep_region_lines(img, regions, scale);
175
176        // Deterministic width-batching (shared with the wasm path).
177        let mut texts = vec![String::new(); lines.len()];
178        for (w, chunk) in width_batches(&lines) {
179            for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
180                texts[i] = text;
181            }
182        }
183
184        // Emit cells in page order, exactly as the sequential walk did.
185        let mut cells = Vec::new();
186        for ((l, t, r, b), text) in bboxes.into_iter().zip(texts) {
187            let text = text.trim().to_string();
188            if text.is_empty() {
189                continue;
190            }
191            cells.push(TextCell { text, l, t, r, b });
192        }
193        Ok(cells)
194    }
195
196    /// Recognize the *word* crops inside the page's table regions (mirroring
197    /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
198    /// skips table labels, so a scanned table would otherwise reach the cell
199    /// matcher with no words at all and dissolve (#173). Returns word-level
200    /// [`TextCell`]s in page points.
201    pub fn ocr_table_words(
202        &mut self,
203        img: &RgbImage,
204        regions: &[Region],
205        scale: f32,
206    ) -> Result<Vec<TextCell>, String> {
207        let (bboxes, lines) = prep_table_words(img, regions, scale);
208        let mut texts = vec![String::new(); lines.len()];
209        for (w, chunk) in width_batches(&lines) {
210            for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
211                texts[i] = text;
212            }
213        }
214        let mut cells = Vec::new();
215        for ((l, t, r, b), text) in bboxes.into_iter().zip(texts) {
216            let text = text.trim().to_string();
217            if text.is_empty() {
218                continue;
219            }
220            cells.push(TextCell { text, l, t, r, b });
221        }
222        Ok(cells)
223    }
224}