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: env_cmd(),
84 lang,
85 psm,
86 tessdata_dir: docling_core::env::nonempty("DOCLING_RS_TESSDATA_DIR"),
87 }
88 }
89}
90
91fn env_cmd() -> String {
94 docling_core::env::nonempty("DOCLING_TESSERACT").unwrap_or_else(|| "tesseract".to_string())
95}
96
97pub 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
111fn 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 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
136fn 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
144fn parse_psm(raw: &str) -> Option<u8> {
147 raw.trim().parse::<u8>().ok().filter(|&n| n <= 13)
148}
149
150pub 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
219fn 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 "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#[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
383fn 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
448type Unit = (f32, f32, f32, f32, String, f32);
451
452fn 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
481fn 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
489const CROP_PAD: u32 = 6;
493const CROP_PAD_RATIO: f32 = 0.3;
503const CROP_PAD_MAX: u32 = 48;
507
508fn crop_pad(h_px: f32) -> u32 {
511 (h_px * CROP_PAD_RATIO)
513 .round()
514 .max(CROP_PAD as f32)
515 .min(CROP_PAD_MAX as f32) as u32
516}
517
518struct Crop {
520 ox: u32,
522 oy: u32,
523 png: Vec<u8>,
524 bounds: (f32, f32, f32, f32),
529}
530
531fn 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
557fn 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
577pub struct TesseractOcr {
579 opts: TesseractOptions,
580 lanes: usize,
582 has_osd: bool,
585}
586
587impl TesseractOcr {
588 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 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 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 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 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 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 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 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 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 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 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
836fn 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 #[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 #[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 #[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, ®ion, 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, ®ion, 0.4).expect("crop");
938 assert_eq!((crop.ox, crop.oy), (34, 34), "6 px floor on a 19 px line");
939 }
940
941 #[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 let own: Unit = (14.0, 14.0, 214.0, 62.0, "Ação".into(), 0.9);
953 let jittered: Unit = (14.0, 6.0, 214.0, 58.0, "Ação".into(), 0.9);
955 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 #[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}