From c7fcee4ed94414c81f68c7da8b6abd93f4a63b7d Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Sat, 10 Oct 2026 22:08:04 +0300 Subject: [PATCH] =?UTF-8?q?OCR:=20a=20scanned=20euro=20sign=20reads=20as?= =?UTF-8?q?=20=E2=82=AC,=20not=20E=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ocrs crate's default alphabet has an E where its recognition model was trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was trained with. the_alphabet_is_the_trained_one checks the alphabet without the models; recognize_text_reads_the_euro_sign reads a scan end to end when they are installed. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 18 ++++++++++++++++++ crates/ocr/src/lib.rs | 15 +++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55af826060..5460a21125 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1697,6 +1697,24 @@ fn backgrounds_and_watermarks_from_files() { /// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models: /// `cargo xtask models`; skipped without them). +#[test] +fn recognize_text_reads_the_euro_sign() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "Total amount due: €250 by Friday").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, scan, None).unwrap(); + s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + let text = page_texts(&s, id)[0].clone(); + // The model was trained with € where the ocrs crate's alphabet has a second E. + assert!(text.contains("€250"), "{text}"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index ae829d965a..a34386c159 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -18,6 +18,11 @@ pub use pdfcraft_fonts::helvetica_width; pub const DETECTION_MODEL: &str = "text-detection.rten"; pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +/// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its +/// output classes follow this order. The ocrs crate's own copy has an `E` where the training had +/// `€`, which made a scanned "€250" read as "E250". +pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + /// The languages the models read (ISO 639-1); all use the Latin alphabet without accents. pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")]; @@ -109,6 +114,7 @@ impl Ocr { let params = ocrs::OcrEngineParams { detection_model: Some(load(&models.detection)?), recognition_model: Some(load(&models.recognition)?), + alphabet: Some(ALPHABET.into()), ..Default::default() }; let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?; @@ -278,6 +284,15 @@ mod tests { } /// The message tells end users what to do; release builds have no cargo. + #[test] + fn the_alphabet_is_the_trained_one() { + let chars: Vec = ALPHABET.chars().collect(); + assert_eq!(chars.len(), 96); + // The euro sign follows the ASCII punctuation (class 44; 0 is the CTC blank), not a second E. + assert_eq!(chars.iter().position(|&c| c == '€'), Some(43)); + assert_eq!(chars.iter().filter(|&&c| c == 'E').count(), 1); + } + #[test] fn missing_models_message_is_for_end_users() { let message = OcrError::NoModels.to_string();