use image::RgbImage;
use ort::session::Session;
use ort::value::Tensor;
use crate::layout::Region;
use crate::ocr_prep::{
batch_input, decode_row_scored, dict_chars, prep_region_lines, prep_table_words, width_batches,
PrepLine, REC_HEIGHT,
};
use crate::pdfium_backend::TextCell;
pub struct OcrModel {
recs: Vec<Session>,
chars: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OcrLang {
#[default]
En,
Ch,
}
impl OcrLang {
pub fn parse(s: &str) -> Option<Self> {
let token = s.trim().to_ascii_lowercase();
let tag = token.strip_prefix("iso:").unwrap_or(&token).trim();
let primary = tag.split(['-', '_']).next().unwrap_or_default();
match primary {
"en" | "eng" | "english" => Some(Self::En),
"ch" | "chinese_cht" | "zh" | "zho" | "chi" | "cmn" | "chinese" | "ch_sim"
| "ch_tra" => Some(Self::Ch),
_ => None,
}
}
pub fn from_env() -> Self {
let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_LANG") else {
return Self::default();
};
Self::parse(&raw).unwrap_or_else(|| {
eprintln!(
"docling-pdf: DOCLING_RS_OCR_LANG={raw:?} names no language the en/ch \
recognizers read ({}); using en",
Self::ACCEPTED
);
Self::default()
})
}
pub const ACCEPTED: &'static str =
"en | ch, or a BCP-47 tag for English or Chinese such as en-US, eng, zh, zh-Hans, zh-TW";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OcrMode {
#[default]
Default,
FullPage,
LayoutRegions,
PdfAwareLayoutRegions,
}
impl OcrMode {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"default" => Some(Self::Default),
"full_page" => Some(Self::FullPage),
"layout_regions" => Some(Self::LayoutRegions),
"pdf_aware_layout_regions" => Some(Self::PdfAwareLayoutRegions),
_ => None,
}
}
pub fn from_env() -> Self {
let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_MODE") else {
return Self::default();
};
Self::parse(&raw).unwrap_or_else(|| {
eprintln!(
"docling-pdf: DOCLING_RS_OCR_MODE={raw:?} is not \
default|full_page|layout_regions|pdf_aware_layout_regions; using default"
);
Self::default()
})
}
pub fn forces_full_page(self) -> bool {
matches!(self, Self::FullPage | Self::LayoutRegions)
}
}
pub fn scale_from_env() -> Option<f32> {
let raw = docling_core::env::nonempty("DOCLING_RS_OCR_SCALE")?;
match raw.parse::<f32>() {
Ok(s) if s > 0.0 && s.is_finite() => Some(s),
_ => {
eprintln!(
"docling-pdf: DOCLING_RS_OCR_SCALE={raw:?} is not a positive number; ignored"
);
None
}
}
}
pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
let want_ch = lang == OcrLang::Ch;
let pick = if want_ch { CH } else { EN };
let (mut rec, mut dict) = (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1));
if !want_ch && (!std::path::Path::new(&rec).exists() || !std::path::Path::new(&dict).exists()) {
let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
eprintln!(
"docling-pdf: English OCR model not found ({rec}); falling back to the \
multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
scripts/install/download_dependencies.sh"
);
(rec, dict) = (ch_rec, ch_dict);
}
}
(
docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
)
}
type Recognized = (String, f32);
impl OcrModel {
pub fn load_with(lang: OcrLang, lanes: usize) -> Result<Self, String> {
let (rec_path, dict_path) = resolve_rec_pair(lang);
let lanes = docling_core::env::parse::<usize>("DOCLING_RS_OCR_SESSIONS")
.filter(|&n| n > 0)
.unwrap_or(lanes)
.clamp(1, 8);
let open = || -> Result<Session, String> {
let builder = Session::builder()
.map_err(|e| format!("ocr: builder: {e}"))?
.with_intra_threads(1)
.map_err(|e| format!("ocr: intra_threads: {e}"))?;
let builder = docling_onnx::apply(builder).map_err(|e| format!("ocr: {e}"))?;
docling_onnx::commit(builder, &rec_path, "rec")
.map_err(|e| format!("ocr: load {rec_path}: {e}"))
};
let recs: Vec<Session> = std::thread::scope(|s| {
let handles: Vec<_> = (0..lanes).map(|_| s.spawn(open)).collect();
handles
.into_iter()
.map(|h| {
h.join()
.map_err(|_| "ocr: session thread panicked".to_string())?
})
.collect::<Result<Vec<_>, String>>()
})?;
let dict = std::fs::read_to_string(&dict_path)
.map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
Ok(Self {
recs,
chars: dict_chars(&dict),
})
}
fn recognize_all(&mut self, lines: &[PrepLine]) -> Result<Vec<(usize, Recognized)>, String> {
let batches = width_batches(lines);
let lanes = self.recs.len().min(batches.len()).max(1);
let chars = &self.chars;
let mut per_batch: Vec<Option<Result<Vec<Recognized>, String>>> =
(0..batches.len()).map(|_| None).collect();
if lanes <= 1 {
for (slot, (w, chunk)) in per_batch.iter_mut().zip(&batches) {
*slot = Some(recognize_batch(&mut self.recs[0], chars, *w, chunk, lines));
}
} else {
std::thread::scope(|s| {
let handles: Vec<_> = self
.recs
.iter_mut()
.take(lanes)
.enumerate()
.map(|(lane, rec)| {
let batches = &batches;
s.spawn(move || {
batches
.iter()
.enumerate()
.filter(|(k, _)| k % lanes == lane)
.map(|(k, (w, chunk))| {
(k, recognize_batch(rec, chars, *w, chunk, lines))
})
.collect::<Vec<_>>()
})
})
.collect();
for h in handles {
for (k, r) in h.join().expect("ocr lane panicked") {
per_batch[k] = Some(r);
}
}
});
}
let mut out = Vec::with_capacity(lines.len());
for ((_, chunk), slot) in batches.iter().zip(per_batch) {
let texts = slot.expect("every batch is assigned a lane")?;
out.extend(chunk.iter().copied().zip(texts));
}
Ok(out)
}
pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
let mut weighted = 0.0f32;
let mut chars = 0usize;
for (_, (text, conf)) in self.recognize_all(lines)? {
let n = text.trim().chars().count();
weighted += conf * n as f32;
chars += n;
}
Ok((weighted, chars))
}
pub fn ocr_page(
&mut self,
img: &RgbImage,
regions: &[Region],
scale: f32,
) -> Result<Vec<(TextCell, f32)>, String> {
let (bboxes, lines) =
crate::timing::timed("ocr.prep", || prep_region_lines(img, regions, scale));
let mut texts = vec![(String::new(), 0.0f32); lines.len()];
crate::timing::timed("ocr.rec", || -> Result<(), String> {
for (i, text) in self.recognize_all(&lines)? {
texts[i] = text;
}
Ok(())
})?;
let mut cells = Vec::new();
for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
let text = text.trim().to_string();
if text.is_empty() {
continue;
}
cells.push((TextCell { text, l, t, r, b }, conf));
}
Ok(cells)
}
pub fn ocr_table_words(
&mut self,
img: &RgbImage,
regions: &[Region],
scale: f32,
) -> Result<Vec<(TextCell, f32)>, String> {
let (bboxes, lines) = prep_table_words(img, regions, scale);
let mut texts = vec![(String::new(), 0.0f32); lines.len()];
for (i, text) in self.recognize_all(&lines)? {
texts[i] = text;
}
let mut cells = Vec::new();
for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
let text = text.trim().to_string();
if text.is_empty() {
continue;
}
cells.push((TextCell { text, l, t, r, b }, conf));
}
Ok(cells)
}
}
fn recognize_batch(
rec: &mut Session,
chars: &[String],
w: usize,
chunk: &[usize],
lines: &[PrepLine],
) -> Result<Vec<(String, f32)>, String> {
let n = chunk.len();
let data = batch_input(w, chunk, lines);
let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
.map_err(|e| format!("ocr: input tensor: {e}"))?;
let outputs = rec
.run(ort::inputs!["x" => input])
.map_err(|e| format!("ocr: rec inference: {e}"))?;
let (shape, probs) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|e| format!("ocr: extract rec: {e}"))?;
let t_len = shape[1] as usize;
let nc = shape[2] as usize;
Ok((0..n)
.map(|i| decode_row_scored(chars, &probs[i * t_len * nc..(i + 1) * t_len * nc], nc))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ocr_lang_accepts_bcp47_tags_for_the_two_recognizers() {
for id in [
"en",
"EN",
" en ",
"en-US",
"en_GB",
"eng",
"english",
"iso:en",
"ISO:en-GB",
"en-Latn-US",
] {
assert_eq!(OcrLang::parse(id), Some(OcrLang::En), "{id:?}");
}
for id in [
"ch",
"zh",
"zho",
"chi",
"cmn",
"chinese",
"ch_sim",
"ch_tra",
"chinese_cht",
"zh-Hans",
"zh-Hant",
"zh-CN",
"zh-TW",
"zh-Hant-HK",
"zh_SG",
"iso:zh-Hans",
] {
assert_eq!(OcrLang::parse(id), Some(OcrLang::Ch), "{id:?}");
}
for id in [
"", "de", "fr-FR", "ja", "deu", "cn", "latin", "iso:", "iso:und", "e",
] {
assert_eq!(OcrLang::parse(id), None, "{id:?}");
}
}
#[test]
fn ocr_mode_ids_parse_and_map_to_forcing() {
for (id, mode, forces) in [
("default", OcrMode::Default, false),
("full_page", OcrMode::FullPage, true),
("layout_regions", OcrMode::LayoutRegions, true),
(
"pdf_aware_layout_regions",
OcrMode::PdfAwareLayoutRegions,
false,
),
] {
assert_eq!(OcrMode::parse(id), Some(mode));
assert_eq!(mode.forces_full_page(), forces, "{id}");
}
assert_eq!(OcrMode::parse(" Full_Page "), Some(OcrMode::FullPage));
assert_eq!(OcrMode::parse("easyocr"), None);
assert_eq!(OcrMode::parse(""), None);
}
}