OCR: a scanned euro sign reads as €, not E (#796)

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 <[email protected]>
This commit is contained in:
mlkonradandClaude Opus 5.5 authored and GitHub committed 2026-10-10 15:08:04 -04:00
1 parent f8334d0cd3
commit c7fcee4ed9
2 files changed
+33

No files matched your search

+18
View File
@@ -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() {
+15
View File
@@ -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<char> = 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();