1use 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TesseractOptions {
45 pub cmd: String,
48 pub lang: Option<String>,
51 pub psm: Option<u8>,
56 pub tessdata_dir: Option<String>,
59}
60
61impl TesseractOptions {
62 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
92fn 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
100fn parse_psm(raw: &str) -> Option<u8> {
103 raw.trim().parse::<u8>().ok().filter(|&n| n <= 13)
104}
105
106pub 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
175fn 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 "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#[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
339fn 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
404type Unit = (f32, f32, f32, f32, String, f32);
407
408fn 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
437fn 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
445const CROP_PAD: u32 = 6;
449const CROP_PAD_RATIO: f32 = 0.3;
459const CROP_PAD_MAX: u32 = 48;
463
464fn crop_pad(h_px: f32) -> u32 {
467 (h_px * CROP_PAD_RATIO)
469 .round()
470 .max(CROP_PAD as f32)
471 .min(CROP_PAD_MAX as f32) as u32
472}
473
474struct Crop {
476 ox: u32,
478 oy: u32,
479 png: Vec<u8>,
480 bounds: (f32, f32, f32, f32),
485}
486
487fn 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
513fn 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
533pub struct TesseractOcr {
535 opts: TesseractOptions,
536 lanes: usize,
538 has_osd: bool,
541}
542
543impl TesseractOcr {
544 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 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 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 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 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 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 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 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 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 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 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 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
808fn 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 #[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 #[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, ®ion, 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, ®ion, 0.4).expect("crop");
897 assert_eq!((crop.ox, crop.oy), (34, 34), "6 px floor on a 19 px line");
898 }
899
900 #[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 let own: Unit = (14.0, 14.0, 214.0, 62.0, "Ação".into(), 0.9);
912 let jittered: Unit = (14.0, 6.0, 214.0, 58.0, "Ação".into(), 0.9);
914 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 #[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}