#![cfg(feature = "ocr")]
use image::{DynamicImage, GrayImage, Luma, RgbaImage};
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
use omniparse::ocr::{
features::{extract, FEATURE_COUNT},
layout::{ConnectedComponentAnalyzer, LayoutAnalyzer, TextRegion, WholeImageAnalyzer},
postprocess::{NoopCorrector, PostProcessor, SymspellCorrector},
preprocess::{ImageprocPreprocessor, PreprocessConfig, Preprocessor},
recognize::{FeatureRecognizer, Prototype, Recognizer},
OcrEngine, OcrEngineBuilder,
};
#[test]
fn preprocess_produces_grayscale_output() {
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(16, 16, image::Rgba([120, 60, 200, 255])));
let pre = ImageprocPreprocessor::new();
let out = pre.process(img).expect("preprocess");
assert_eq!(out.dimensions(), (16, 16));
}
#[test]
fn sauvola_binarizes_gradient_background() {
use omniparse::ocr::preprocess::{sauvola, BinarizeMode, PreprocessConfig, ImageprocPreprocessor};
let (w, h) = (64u32, 32u32);
let mut img = GrayImage::new(w, h);
for y in 0..h {
for x in 0..w {
let grad = (100 + (x as i32 * 2)).clamp(0, 255) as u8;
img.put_pixel(x, y, Luma([grad]));
}
}
for y in 10..20 {
for x in 8..56 {
img.put_pixel(x, y, Luma([10]));
}
}
let bin = sauvola(&img, 15, 0.2, 128.0);
let mut ink_in = 0;
let mut ink_out = 0;
for y in 0..h {
for x in 0..w {
let is_ink = bin.get_pixel(x, y)[0] < 128;
let in_bar = (10..20).contains(&y) && (8..56).contains(&x);
if is_ink && in_bar { ink_in += 1; }
if is_ink && !in_bar { ink_out += 1; }
}
}
assert!(ink_in > 100, "expected ink inside bar, got {ink_in}");
assert!(
ink_in > 10 * ink_out,
"too much ink outside bar: in={ink_in} out={ink_out}"
);
let pre = ImageprocPreprocessor::with_config(PreprocessConfig {
binarize: BinarizeMode::Sauvola { window: 15, k: 0.2, r: 128.0 },
despeckle_radius: 0,
deskew: false,
..Default::default()
});
let _ = pre.process(image::DynamicImage::ImageLuma8(img)).unwrap();
}
#[test]
fn clahe_preserves_image_size() {
use omniparse::ocr::preprocess::clahe;
let img = GrayImage::from_pixel(64, 32, Luma([100]));
let out = clahe(&img, 8, 2.0);
assert_eq!(out.dimensions(), (64, 32));
}
#[test]
fn preprocess_binarizes_gradient_into_two_levels() {
let mut gray = GrayImage::new(32, 8);
for y in 0..8u32 {
for x in 0..32u32 {
gray.put_pixel(x, y, Luma([(x * 8) as u8]));
}
}
let pre = ImageprocPreprocessor::with_config(PreprocessConfig {
binarize: omniparse::ocr::preprocess::BinarizeMode::Otsu,
despeckle_radius: 0,
deskew: false,
deskew_min_radians: 0.0,
..Default::default()
});
let out = pre.process(DynamicImage::ImageLuma8(gray)).unwrap();
let mut values = std::collections::HashSet::new();
for p in out.pixels() {
values.insert(p[0]);
}
assert!(values.contains(&0));
assert!(values.contains(&255));
assert_eq!(values.len(), 2);
}
#[test]
fn whole_image_analyzer_returns_single_region() {
let img = GrayImage::new(20, 10);
let analyzer = WholeImageAnalyzer;
let regions = analyzer.detect_regions(&img).unwrap();
assert_eq!(regions.len(), 1);
assert_eq!(
regions[0],
TextRegion {
x: 0,
y: 0,
width: 20,
height: 10
}
);
}
#[test]
fn connected_components_finds_three_glyphs() {
let mut img = GrayImage::from_pixel(60, 20, Luma([255]));
for (cx, cy) in [(5, 5), (25, 5), (45, 5)] {
for dy in 0..6 {
for dx in 0..6 {
img.put_pixel(cx + dx, cy + dy, Luma([0]));
}
}
}
let analyzer = ConnectedComponentAnalyzer::default();
let regions = analyzer.detect_regions(&img).unwrap();
assert_eq!(regions.len(), 3, "expected 3 components, got {regions:?}");
}
#[test]
fn connected_components_filters_tiny_noise() {
let mut img = GrayImage::from_pixel(40, 20, Luma([255]));
for dy in 0..6 {
for dx in 0..6 {
img.put_pixel(5 + dx, 5 + dy, Luma([0]));
}
}
img.put_pixel(30, 10, Luma([0]));
let analyzer = ConnectedComponentAnalyzer {
min_dimension: 2,
..Default::default()
};
let regions = analyzer.detect_regions(&img).unwrap();
assert_eq!(regions.len(), 1);
}
#[test]
fn feature_vector_has_expected_length() {
let img = GrayImage::from_pixel(10, 10, Luma([255]));
let features = extract(&img);
assert_eq!(features.len(), FEATURE_COUNT);
}
#[test]
fn feature_vector_distinguishes_filled_from_empty() {
let empty = GrayImage::from_pixel(10, 10, Luma([255]));
let filled = GrayImage::from_pixel(10, 10, Luma([0]));
let ev = extract(&empty);
let fv = extract(&filled);
assert!(fv[1] > 0.9);
assert!(ev[1] < 0.1);
}
#[test]
fn recognizer_picks_nearest_prototype() {
let black = GrayImage::from_pixel(8, 8, Luma([0]));
let white = GrayImage::from_pixel(8, 8, Luma([255]));
let prototypes = vec![
Prototype {
label: 'X',
features: extract(&black),
},
Prototype {
label: 'O',
features: extract(&white),
},
];
let recog = FeatureRecognizer::new(prototypes);
let region = TextRegion {
x: 0,
y: 0,
width: 8,
height: 8,
};
let black_result = recog.recognize(&black, ®ion).unwrap();
assert_eq!(black_result.text, "X");
assert!(black_result.confidence > 0.9);
let white_result = recog.recognize(&white, ®ion).unwrap();
assert_eq!(white_result.text, "O");
assert!(white_result.confidence > 0.9);
}
#[test]
fn recognizer_with_no_prototypes_returns_empty_string() {
let img = GrayImage::from_pixel(8, 8, Luma([0]));
let recog = FeatureRecognizer::new(Vec::new());
let region = TextRegion {
x: 0,
y: 0,
width: 8,
height: 8,
};
let line = recog.recognize(&img, ®ion).unwrap();
assert_eq!(line.text, "");
assert_eq!(line.confidence, 0.0);
}
#[test]
fn noop_corrector_passes_through() {
assert_eq!(NoopCorrector.correct("helo wrld"), "helo wrld");
}
#[test]
fn symspell_corrector_fixes_single_edit() {
let corrector = SymspellCorrector::with_default_wordlist().expect("build symspell");
let corrected = corrector.correct("teh");
assert_eq!(corrected.trim(), "the");
}
#[test]
fn symspell_corrector_preserves_case_on_first_letter() {
let corrector = SymspellCorrector::with_default_wordlist().expect("build symspell");
let corrected = corrector.correct("Teh");
assert_eq!(corrected.trim(), "The");
}
#[test]
fn engine_runs_end_to_end_with_custom_recognizer() {
let mut img = RgbaImage::from_pixel(20, 20, image::Rgba([255, 255, 255, 255]));
for dy in 5..15 {
for dx in 5..15 {
img.put_pixel(dx, dy, image::Rgba([0, 0, 0, 255]));
}
}
let gray = image::imageops::grayscale(&DynamicImage::ImageRgba8(img.clone()));
let mut glyph = image::imageops::crop_imm(&gray, 5, 5, 10, 10).to_image();
let t = imageproc::contrast::otsu_level(&glyph);
glyph = imageproc::contrast::threshold(&glyph, t);
let protos = vec![Prototype {
label: 'X',
features: extract(&glyph),
}];
let engine: OcrEngine = OcrEngineBuilder::default()
.recognizer(FeatureRecognizer::new(protos))
.build();
let out = engine.recognize(DynamicImage::ImageRgba8(img)).expect("ocr");
assert!(out.text.contains('X'), "expected 'X' in {:?}", out.text);
assert!(out.mean_confidence > 0.0);
}
#[test]
fn engine_renders_two_lines_with_newline_and_word_spaces() {
use image::{DynamicImage, RgbaImage};
use omniparse::ocr::{prototypes::BUNDLED_GLYPHS, OcrEngine};
let find = |ch: char| {
BUNDLED_GLYPHS.iter().find(|(c, _)| *c == ch).unwrap().1
};
let scale: u32 = 4;
let gap: u32 = 60; let line_gap: u32 = 80;
let glyph_w = 7 * scale;
let glyph_h = 9 * scale;
let margin: u32 = 20;
let line1_w = 2 * glyph_w + gap; let canvas_w = line1_w + 2 * margin;
let canvas_h = 2 * glyph_h + line_gap + 2 * margin;
let mut img = RgbaImage::from_pixel(canvas_w, canvas_h, image::Rgba([255, 255, 255, 255]));
let draw_glyph = |img: &mut RgbaImage, art: &str, ox: u32, oy: u32| {
let rows: Vec<&str> = art.lines().map(str::trim_end).filter(|r| !r.is_empty()).collect();
for (y, row) in rows.iter().enumerate() {
for (x, ch) in row.chars().enumerate() {
if ch == '#' {
for dy in 0..scale {
for dx in 0..scale {
img.put_pixel(
ox + x as u32 * scale + dx,
oy + y as u32 * scale + dy,
image::Rgba([0, 0, 0, 255]),
);
}
}
}
}
}
};
let h_art = find('H');
let i_art = find('I');
draw_glyph(&mut img, h_art, margin, margin);
draw_glyph(&mut img, h_art, margin + glyph_w + gap, margin);
draw_glyph(&mut img, i_art, margin, margin + glyph_h + line_gap);
let engine = omniparse::ocr::OcrEngineBuilder::default()
.config(omniparse::ocr::OcrConfig {
min_confidence: 0.0,
..Default::default()
})
.build();
let out = engine.recognize(DynamicImage::ImageRgba8(img)).unwrap();
let rendered_lines: Vec<&str> = out.text.lines().collect();
assert!(
rendered_lines.len() >= 2,
"expected ≥2 lines in {:?}",
out.text
);
assert!(
rendered_lines[0].contains(' '),
"expected a space in the first line of {:?}",
out.text
);
}
#[test]
fn script_detection_identifies_latin_and_cyrillic() {
use omniparse::ocr::script::{dominant_script, Script};
assert_eq!(dominant_script("hello world"), Some(Script::Latin));
assert_eq!(dominant_script("Привет мир"), Some(Script::Cyrillic));
assert_eq!(dominant_script("abc 123"), Some(Script::Latin));
assert_eq!(dominant_script("12345"), None);
}
#[test]
fn bilateral_filter_preserves_edges() {
use image::{GrayImage, Luma};
use omniparse::ocr::preprocess::bilateral_filter;
let mut img = GrayImage::new(20, 20);
for y in 0..20u32 {
for x in 0..20u32 {
img.put_pixel(x, y, Luma([if x < 10 { 0 } else { 255 }]));
}
}
let out = bilateral_filter(&img, 2, 3.0, 20.0);
let left = out.get_pixel(2, 10)[0];
let right = out.get_pixel(17, 10)[0];
assert!(left < 60, "left side should stay dark: {left}");
assert!(right > 195, "right side should stay bright: {right}");
}
#[test]
fn unsharp_mask_amplifies_contrast_at_edge() {
use image::{GrayImage, Luma};
use omniparse::ocr::preprocess::unsharp_mask;
let mut img = GrayImage::new(20, 20);
for y in 0..20u32 {
for x in 0..20u32 {
img.put_pixel(x, y, Luma([if x < 10 { 100 } else { 150 }]));
}
}
let out = unsharp_mask(&img, 2, 1.5);
let at_edge_left = out.get_pixel(9, 10)[0];
let at_edge_right = out.get_pixel(10, 10)[0];
assert!(at_edge_left <= 100);
assert!(at_edge_right >= 150);
}
#[test]
fn ocr_cache_caches_identical_input() {
use omniparse::ocr::cache::{OcrAttemptSnapshot, OcrCache};
let cache = OcrCache::new(4);
let bytes = b"dummy-image-bytes";
let key = OcrCache::key(bytes);
assert!(cache.get(&key).is_none());
cache.put(
key,
OcrAttemptSnapshot::NoTextFound { mean_confidence: 0.42, regions: 3 },
);
let hit = cache.get(&key).expect("should hit");
match hit {
OcrAttemptSnapshot::NoTextFound { mean_confidence, regions } => {
assert!((mean_confidence - 0.42).abs() < 1e-6);
assert_eq!(regions, 3);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn ocr_cache_evicts_lru_entries() {
use omniparse::ocr::cache::{OcrAttemptSnapshot, OcrCache};
let cache = OcrCache::new(2);
let a = [1u8; 32];
let b = [2u8; 32];
let c = [3u8; 32];
cache.put(a, OcrAttemptSnapshot::Disabled);
cache.put(b, OcrAttemptSnapshot::Disabled);
assert!(cache.get(&a).is_some());
cache.put(c, OcrAttemptSnapshot::Disabled); assert!(cache.get(&a).is_some());
assert!(cache.get(&b).is_none());
assert!(cache.get(&c).is_some());
}
#[test]
fn scale_normalize_keeps_high_confidence_across_sizes() {
use image::{GrayImage, Luma};
use omniparse::ocr::features::extract;
use omniparse::ocr::layout::TextRegion;
use omniparse::ocr::recognize::{FeatureRecognizer, Prototype, Recognizer};
let mut proto_img = GrayImage::from_pixel(8, 8, Luma([255]));
for y in 1..7 {
for x in 1..7 {
proto_img.put_pixel(x, y, Luma([0]));
}
}
let prototypes = vec![Prototype {
label: '█',
features: extract(&proto_img),
}];
let mut query_img = GrayImage::from_pixel(40, 40, Luma([255]));
for y in 5..35 {
for x in 5..35 {
query_img.put_pixel(x, y, Luma([0]));
}
}
let region = TextRegion { x: 0, y: 0, width: 40, height: 40 };
let raw = FeatureRecognizer::new(prototypes.clone())
.recognize(&query_img, ®ion)
.unwrap();
let normalized = FeatureRecognizer::new(prototypes)
.with_normalize_height(Some(16))
.recognize(&query_img, ®ion)
.unwrap();
assert!(
normalized.confidence >= raw.confidence - 1e-4,
"normalized {} vs raw {}",
normalized.confidence,
raw.confidence
);
}
#[test]
fn beam_search_prefers_dictionary_words() {
use omniparse::ocr::layout::TextRegion;
use omniparse::ocr::postprocess::{beam_search_line, DEFAULT_WORDLIST};
use omniparse::ocr::recognize::RecognizedLine;
let region = TextRegion { x: 0, y: 0, width: 10, height: 10 };
let glyphs = vec![
RecognizedLine {
text: "z".into(),
confidence: 0.5,
region: region.clone(),
alternatives: vec![('z', 0.10), ('t', 0.12)],
},
RecognizedLine {
text: "n".into(),
confidence: 0.5,
region: region.clone(),
alternatives: vec![('n', 0.10), ('h', 0.12)],
},
RecognizedLine {
text: "e".into(),
confidence: 0.5,
region: region.clone(),
alternatives: vec![('e', 0.10), ('e', 0.10)],
},
];
let out = beam_search_line(&glyphs, 8, DEFAULT_WORDLIST);
assert_eq!(out, "the", "expected dictionary word, got {out:?}");
}
#[test]
fn dedupe_prototypes_reduces_label_count() {
use omniparse::ocr::features::FEATURE_COUNT;
use omniparse::ocr::prototypes::dedupe_prototypes;
use omniparse::ocr::recognize::Prototype;
let mut protos = Vec::new();
for i in 0..10 {
let mut f = vec![0.0f32; FEATURE_COUNT];
f[0] = i as f32 * 0.01;
protos.push(Prototype { label: 'A', features: f });
}
for i in 0..2 {
let mut f = vec![0.0f32; FEATURE_COUNT];
f[0] = 5.0 + i as f32;
protos.push(Prototype { label: 'B', features: f });
}
assert_eq!(protos.len(), 12);
let reduced = dedupe_prototypes(protos, 3);
let a = reduced.iter().filter(|p| p.label == 'A').count();
let b = reduced.iter().filter(|p| p.label == 'B').count();
assert_eq!(a, 3);
assert_eq!(b, 2);
}
#[test]
fn text_line_filter_rejects_height_outliers() {
use omniparse::ocr::layout::{filter_text_lines, TextRegion};
let mut regions = Vec::new();
for (i, h) in [30u32, 31, 30, 29].iter().enumerate() {
regions.push(TextRegion { x: i as u32 * 40, y: 0, width: 30, height: *h });
}
for (i, h) in [10u32, 50, 20].iter().enumerate() {
regions.push(TextRegion { x: i as u32 * 40, y: 100, width: 30, height: *h });
}
let kept = filter_text_lines(regions);
assert_eq!(kept.len(), 4);
assert!(kept.iter().all(|r| r.y == 0));
}
#[test]
fn bigram_rerank_prefers_english_like_pair() {
use omniparse::ocr::bigram::BigramRanker;
use omniparse::ocr::layout::TextRegion;
use omniparse::ocr::recognize::RecognizedLine;
let region = TextRegion { x: 0, y: 0, width: 10, height: 10 };
let glyphs = vec![
RecognizedLine {
text: "t".into(),
confidence: 0.5,
region: region.clone(),
alternatives: vec![('t', 0.1), ('l', 0.11)],
},
RecognizedLine {
text: "z".into(),
confidence: 0.5,
region: region.clone(),
alternatives: vec![('z', 0.10), ('h', 0.12)],
},
];
let ranker = BigramRanker::english();
let out = ranker.rerank_line(&glyphs);
assert_eq!(out, "th", "expected bigram to flip z→h, got {out:?}");
}
#[test]
fn stroke_width_filter_rejects_photographic_region() {
use image::{GrayImage, Luma};
use omniparse::ocr::layout::{filter_by_stroke_width_constancy, TextRegion};
let (w, h) = (80u32, 40u32);
let mut img = GrayImage::from_pixel(w, h, Luma([255]));
for y in 5..35 {
for x in 10..16 {
img.put_pixel(x, y, Luma([0]));
}
}
for x in 40..70 {
img.put_pixel(x, 8, Luma([0])); }
for y in 15..27 {
for x in 45..57 {
img.put_pixel(x, y, Luma([0])); }
}
let regions = vec![
TextRegion { x: 8, y: 3, width: 10, height: 34 },
TextRegion { x: 38, y: 3, width: 34, height: 34 },
];
let loose = filter_by_stroke_width_constancy(&img, regions.clone(), 0.9);
assert_eq!(loose.len(), 2, "both regions should pass loose filter: {loose:?}");
let tight = filter_by_stroke_width_constancy(&img, regions, 0.5);
assert!(
tight.iter().any(|r| r.x == 8),
"bar region missing at 0.5 threshold: {tight:?}"
);
assert!(
!tight.iter().any(|r| r.x == 38),
"noise region leaked through at 0.5: {tight:?}"
);
}
#[test]
fn mser_finds_dark_blob_on_uniform_background() {
use image::Luma;
use omniparse::ocr::layout::LayoutAnalyzer;
use omniparse::ocr::mser::{MserConfig, MserLayoutAnalyzer};
let (w, h) = (80u32, 60u32);
let mut img = GrayImage::from_pixel(w, h, Luma([220]));
for y in 20..40 {
for x in 30..50 {
img.put_pixel(x, y, Luma([30]));
}
}
let analyzer = MserLayoutAnalyzer::with_config(MserConfig {
min_area: 100,
max_area: 5000,
max_variation: 0.9,
..Default::default()
});
let regions = analyzer.detect_regions(&img).unwrap();
assert!(!regions.is_empty(), "MSER should find the blob");
let inside = regions.iter().any(|r| {
r.x <= 30 && r.y <= 20 && r.x + r.width >= 50 && r.y + r.height >= 40
});
assert!(inside, "no region enclosed the blob: {regions:?}");
}
#[test]
fn nms_merges_overlapping_regions() {
use omniparse::ocr::layout::{nms_regions, TextRegion};
let a = TextRegion { x: 10, y: 10, width: 20, height: 20 };
let b = TextRegion { x: 12, y: 12, width: 20, height: 20 }; let c = TextRegion { x: 100, y: 100, width: 20, height: 20 };
let merged = nms_regions(vec![a.clone(), b, c.clone()], 0.5);
assert_eq!(merged.len(), 2);
assert!(merged.iter().any(|r| r.x == 100));
}
#[test]
fn swt_finds_text_on_noisy_background() {
use image::{GrayImage, Luma};
use omniparse::ocr::layout::LayoutAnalyzer;
use omniparse::ocr::swt::{Polarity, SwtConfig, SwtLayoutAnalyzer};
let (w, h) = (200u32, 80u32);
let mut img = GrayImage::from_pixel(w, h, Luma([180]));
for y in 0..h {
for x in 0..w {
let v = ((x * 7 + y * 13) % 31) as u8;
if v < 4 {
img.put_pixel(x, y, Luma([40 + v * 3]));
}
}
}
for y in 30..42 {
for x in 40..160 {
img.put_pixel(x, y, Luma([0]));
}
}
let cfg = SwtConfig {
height_min: 4,
height_max: 60,
min_area: 10,
max_stroke_width: 30,
max_cv: 0.9,
aspect_min: 0.1,
aspect_max: 50.0,
polarity: Polarity::DarkOnLight,
..Default::default()
};
let analyzer = SwtLayoutAnalyzer::with_config(cfg);
let regions = analyzer.detect_regions(&img).unwrap();
eprintln!("regions: {regions:?}");
assert!(
!regions.is_empty(),
"SWT should find the bar as a region"
);
let inside = regions.iter().any(|r| {
r.x >= 30 && r.y >= 20 && r.x + r.width <= 170 && r.y + r.height <= 60
});
assert!(inside, "no region overlapped the bar: {regions:?}");
}
#[test]
fn pdf_ocr_reports_status_on_text_layer_pdf() {
let Ok(bytes) = std::fs::read("test_data/document/sample.pdf") else {
return;
};
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("OMNIPARSE_OCR", "1");
}
let result = omniparse::extract_from_bytes(&bytes, Some("application/pdf"));
unsafe {
std::env::remove_var("OMNIPARSE_OCR");
}
let Ok(res) = result else { return };
let has_text = matches!(&res.content, omniparse::Content::Text(t) if !t.trim().is_empty());
let has_status = res.metadata.get("ocr_status").is_some();
assert!(has_text || has_status, "expected text or OCR status on {res:?}");
}
#[test]
fn prototypes_round_trip_through_json() {
use omniparse::ocr::prototypes::{
bundled_prototypes, load_prototypes_json, save_prototypes_json,
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("protos.json");
let original = bundled_prototypes();
save_prototypes_json(&original, &path).unwrap();
let reloaded = load_prototypes_json(&path).unwrap();
assert_eq!(original.len(), reloaded.len());
for (a, b) in original.iter().zip(reloaded.iter()) {
assert_eq!(a.label, b.label);
assert_eq!(a.features, b.features);
}
}
#[test]
fn default_prototypes_cover_uppercase_and_digits() {
use omniparse::ocr::prototypes::BUNDLED_GLYPHS;
let labels: std::collections::HashSet<char> =
BUNDLED_GLYPHS.iter().map(|(c, _)| *c).collect();
for ch in '0'..='9' {
assert!(labels.contains(&ch), "missing prototype for {ch}");
}
for ch in 'A'..='Z' {
assert!(labels.contains(&ch), "missing prototype for {ch}");
}
}
#[test]
fn default_recognizer_identifies_its_own_prototype_bitmaps() {
use omniparse::ocr::prototypes::{prototype_from_art, BUNDLED_GLYPHS};
let recog = FeatureRecognizer::with_default_prototypes();
for &label in &['0', '7', 'A', 'H', 'X'] {
let (_, art) = BUNDLED_GLYPHS
.iter()
.find(|(c, _)| *c == label)
.expect("label present");
let proto = prototype_from_art(label, art);
let rows: Vec<&str> = art
.lines()
.map(str::trim_end)
.filter(|r| !r.is_empty())
.collect();
let height = rows.len() as u32;
let width = rows.iter().map(|r| r.len()).max().unwrap_or(0) as u32;
let mut img = GrayImage::from_pixel(width, height, Luma([255]));
for (y, row) in rows.iter().enumerate() {
for (x, ch) in row.chars().enumerate() {
if ch == '#' {
img.put_pixel(x as u32, y as u32, Luma([0]));
}
}
}
let region = TextRegion {
x: 0,
y: 0,
width,
height,
};
let line = recog.recognize(&img, ®ion).unwrap();
assert_eq!(
line.text,
label.to_string(),
"expected {label}, got {:?} (prototype features: {:?})",
line.text,
proto.features
);
assert!(line.confidence > 0.9, "low confidence for {label}: {}", line.confidence);
}
}
#[test]
fn image_parser_hookup_populates_ocr_text_when_enabled() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use image::{DynamicImage, Luma};
use omniparse::{extract_from_bytes, Content, MetadataValue};
let (_, art) = omniparse::ocr::prototypes::BUNDLED_GLYPHS
.iter()
.find(|(c, _)| *c == 'H')
.unwrap();
let rows: Vec<&str> = art.lines().map(str::trim_end).filter(|r| !r.is_empty()).collect();
let scale = 3u32;
let margin = 8u32;
let inner_w = rows[0].len() as u32 * scale;
let inner_h = rows.len() as u32 * scale;
let gw = inner_w + 2 * margin;
let gh = inner_h + 2 * margin;
let mut img = GrayImage::from_pixel(gw, gh, Luma([255]));
for (y, row) in rows.iter().enumerate() {
for (x, ch) in row.chars().enumerate() {
if ch == '#' {
for dy in 0..scale {
for dx in 0..scale {
img.put_pixel(
margin + x as u32 * scale + dx,
margin + y as u32 * scale + dy,
Luma([0]),
);
}
}
}
}
}
let mut buf = Vec::new();
DynamicImage::ImageLuma8(img)
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
unsafe { std::env::remove_var("OMNIPARSE_OCR") };
let quiet = extract_from_bytes(&buf, Some("image/png")).unwrap();
assert!(matches!(quiet.content, Content::None));
assert!(quiet.metadata.get("ocr_applied").is_none());
unsafe { std::env::set_var("OMNIPARSE_OCR", "1") };
let loud = extract_from_bytes(&buf, Some("image/png")).unwrap();
match loud.content {
Content::Text(t) => assert!(t.contains('H'), "expected 'H' in {t:?}"),
other => panic!("expected Content::Text, got {other:?}"),
}
assert_eq!(
loud.metadata.get("ocr_applied"),
Some(&MetadataValue::Boolean(true))
);
unsafe { std::env::remove_var("OMNIPARSE_OCR") };
}
#[test]
fn runtime_enabled_respects_env_var() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::remove_var("OMNIPARSE_OCR");
}
assert!(!omniparse::ocr::runtime_enabled());
unsafe {
std::env::set_var("OMNIPARSE_OCR", "1");
}
assert!(omniparse::ocr::runtime_enabled());
unsafe {
std::env::remove_var("OMNIPARSE_OCR");
}
}