use djvu_rs::jb2_encode::{Jb2EncodeOptions, encode_jb2_dict_with_options};
use djvu_rs::ocr::{OcrBackend, OcrOptions};
use djvu_rs::text::TextLayer;
use djvu_rs::{Bitmap, GrayPixmap, Pixmap, quality};
#[cfg(feature = "ocr-tesseract")]
use djvu_rs::ocr_tesseract::TesseractBackend;
fn mask_to_gray(bm: &Bitmap) -> GrayPixmap {
let mut data = vec![0u8; (bm.width * bm.height) as usize];
for y in 0..bm.height {
for x in 0..bm.width {
data[(y * bm.width + x) as usize] = if bm.get(x, y) { 0 } else { 255 };
}
}
GrayPixmap {
width: bm.width,
height: bm.height,
data,
}
}
fn mask_to_pixmap(bm: &Bitmap) -> Pixmap {
let mut pm = Pixmap::white(bm.width, bm.height);
for y in 0..bm.height {
for x in 0..bm.width {
if bm.get(x, y) {
pm.set_rgb(x, y, 0, 0, 0);
}
}
}
pm
}
fn levenshtein<T: PartialEq>(a: &[T], b: &[T]) -> usize {
let (n, m) = (a.len(), b.len());
if n == 0 {
return m;
}
if m == 0 {
return n;
}
let mut prev: Vec<usize> = (0..=m).collect();
let mut cur = vec![0usize; m + 1];
for i in 1..=n {
cur[0] = i;
for j in 1..=m {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
core::mem::swap(&mut prev, &mut cur);
}
prev[m]
}
fn normalize(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn char_accuracy(a: &str, b: &str) -> f64 {
let (a, b) = (normalize(a), normalize(b));
let ac: Vec<char> = a.chars().collect();
let bc: Vec<char> = b.chars().collect();
let denom = ac.len().max(bc.len()).max(1);
1.0 - levenshtein(&ac, &bc) as f64 / denom as f64
}
fn word_accuracy(a: &str, b: &str) -> f64 {
let (a, b) = (normalize(a), normalize(b));
let aw: Vec<&str> = a.split_whitespace().collect();
let bw: Vec<&str> = b.split_whitespace().collect();
let denom = aw.len().max(bw.len()).max(1);
1.0 - levenshtein(&aw, &bw) as f64 / denom as f64
}
fn build_backend() -> Option<Box<dyn OcrBackend>> {
#[cfg(feature = "ocr-tesseract")]
{
let probe = Pixmap::white(32, 32);
let backend = TesseractBackend::new();
if backend.recognize(&probe, &OcrOptions::default()).is_ok() {
return Some(Box::new(backend));
}
eprintln!(
"note: ocr-tesseract feature is compiled in but Tesseract could not \
recognize a smoke page (missing tessdata/eng.traineddata?) — falling \
back to structural-only output."
);
None
}
#[cfg(not(feature = "ocr-tesseract"))]
{
None
}
}
fn ocr_text(backend: &dyn OcrBackend, pixmap: &Pixmap) -> Option<String> {
backend
.recognize(pixmap, &OcrOptions::default())
.ok()
.map(|layer: TextLayer| layer.text)
}
struct Point {
label: &'static str,
opts: Jb2EncodeOptions,
}
fn run(path: &str, points: &[Point], ocr_pages: usize) {
let Ok(data) = std::fs::read(path) else {
println!("{path}: missing");
return;
};
let Ok(doc) = djvu_rs::DjVuDocument::parse(&data) else {
println!("{path}: parse fail");
return;
};
let mut masks = Vec::new();
for i in 0..doc.page_count() {
if let Ok(page) = doc.page(i)
&& let Ok(Some(m)) = page.extract_mask()
{
masks.push(m);
}
}
if masks.is_empty() {
println!("{path}: no bitonal mask pages found, skipping");
return;
}
let base_opts = Jb2EncodeOptions::default();
let base_total: usize = masks
.iter()
.map(|m| encode_jb2_dict_with_options(m, &[], &base_opts).len())
.sum();
let n_ocr = ocr_pages.min(masks.len());
println!(
"== {} ({} masks total, lossless Sjbz = {} B, OCR over first {} page(s)) ==",
path,
masks.len(),
base_total,
n_ocr
);
let backend = build_backend();
let baseline_ocr: Vec<Option<String>> = if let Some(b) = backend.as_deref() {
masks[..n_ocr]
.iter()
.map(|m| ocr_text(b, &mask_to_pixmap(m)))
.collect()
} else {
vec![None; n_ocr]
};
for point in points {
let mut total = 0usize;
let (mut ssim_sum, mut n) = (0.0f64, 0usize);
let mut decoded: Vec<Bitmap> = Vec::with_capacity(masks.len());
for m in &masks {
let enc = encode_jb2_dict_with_options(m, &[], &point.opts);
total += enc.len();
match djvu_rs::jb2::decode(&enc, None) {
Ok(dec) if dec.width == m.width && dec.height == m.height => {
let q = quality::compare_gray(&mask_to_gray(m), &mask_to_gray(&dec));
ssim_sum += q.ssim;
n += 1;
decoded.push(dec);
}
_ => decoded.push(m.clone()),
}
}
let delta = total as i64 - base_total as i64;
let (char_acc, word_acc) = if let Some(b) = backend.as_deref() {
let mut char_sum = 0.0f64;
let mut word_sum = 0.0f64;
let mut m = 0usize;
for i in 0..n_ocr {
let Some(base_text) = baseline_ocr[i].as_deref() else {
continue;
};
let Some(lossy_text) = ocr_text(b, &mask_to_pixmap(&decoded[i])) else {
continue;
};
char_sum += char_accuracy(base_text, &lossy_text);
word_sum += word_accuracy(base_text, &lossy_text);
m += 1;
}
if m > 0 {
(
Some(100.0 * char_sum / m as f64),
Some(100.0 * word_sum / m as f64),
)
} else {
(None, None)
}
} else {
(None, None)
};
let fmt_pct = |v: Option<f64>| {
v.map(|x| format!("{x:6.2}%"))
.unwrap_or_else(|| " n/a ".to_string())
};
println!(
" {:<12} Sjbz={:>10} B ({:+6.2}%) SSIM={:.5} OCR char-agree={} word-agree={}",
point.label,
total,
100.0 * delta as f64 / base_total as f64,
ssim_sum / n.max(1) as f64,
fmt_pct(char_acc),
fmt_pct(word_acc),
);
}
}
fn main() {
let ocr_pages: usize = std::env::var("OCR_QA_PAGES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let text_points = [
Point {
label: "lossless",
opts: Jb2EncodeOptions::default(),
},
Point {
label: "lossy_text 2%",
opts: Jb2EncodeOptions::lossy_text(),
},
Point {
label: "lossy 5%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.05),
},
Point {
label: "lossy 8%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.08),
},
Point {
label: "lossy 10%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.10),
},
];
#[allow(clippy::needless_update)]
let scan_points = [
Point {
label: "lossless",
opts: Jb2EncodeOptions::default(),
},
Point {
label: "despeckle 8",
opts: Jb2EncodeOptions {
despeckle: Some(8),
..Jb2EncodeOptions::default()
},
},
Point {
label: "lossy_text 2%",
opts: Jb2EncodeOptions::lossy_text(),
},
Point {
label: "lossy 5%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.05),
},
Point {
label: "lossy 8%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.08),
},
Point {
label: "lossy 10%",
opts: Jb2EncodeOptions::with_lossy_threshold(0.10),
},
];
if build_backend().is_none() {
println!(
"OCR backend not runnable in this build/environment — printing structural \
(Sjbz/SSIM) columns only. Rebuild with `--features ocr-tesseract` and a \
working system Tesseract install for OCR-agreement numbers. See the module \
doc comment in examples/ocr_qa.rs for the manual run recipe.\n"
);
}
run("tests/corpus/watchmaker.djvu", &text_points, ocr_pages);
run(
"tests/corpus/pathogenic_bacteria_1896.djvu",
&scan_points,
ocr_pages,
);
}
#[cfg(test)]
mod tests {
use super::*;
use djvu_rs::ocr::OcrError;
struct MockBackend;
impl OcrBackend for MockBackend {
fn recognize(&self, pixmap: &Pixmap, _options: &OcrOptions) -> Result<TextLayer, OcrError> {
let dark = pixmap.data.chunks_exact(4).filter(|p| p[0] < 128).count();
Ok(TextLayer {
text: format!("dark={dark}"),
zones: Vec::new(),
})
}
}
#[test]
fn levenshtein_identical_is_zero() {
let a: Vec<char> = "hello".chars().collect();
assert_eq!(levenshtein(&a, &a), 0);
}
#[test]
fn levenshtein_empty_vs_nonempty_is_length() {
let a: Vec<char> = "hello".chars().collect();
let b: Vec<char> = Vec::new();
assert_eq!(levenshtein(&a, &b), 5);
assert_eq!(levenshtein(&b, &a), 5);
}
#[test]
fn levenshtein_single_substitution() {
let a: Vec<char> = "cat".chars().collect();
let b: Vec<char> = "bat".chars().collect();
assert_eq!(levenshtein(&a, &b), 1);
}
#[test]
fn char_accuracy_identical_is_one() {
assert_eq!(char_accuracy("hello world", "hello world"), 1.0);
}
#[test]
fn char_accuracy_normalizes_whitespace() {
assert_eq!(char_accuracy("hello\nworld", "hello world"), 1.0);
}
#[test]
fn char_accuracy_total_mismatch_is_zero() {
assert_eq!(char_accuracy("aaaa", "bbbb"), 0.0);
}
#[test]
fn word_accuracy_one_word_swapped_of_four() {
let acc = word_accuracy("the quick brown fox", "the quick brown cat");
assert_eq!(acc, 0.75);
}
#[test]
fn word_accuracy_empty_vs_empty_is_one() {
assert_eq!(word_accuracy("", ""), 1.0);
}
#[test]
fn mock_backend_agrees_with_itself() {
let bm = Bitmap::new(8, 8);
let pm = mask_to_pixmap(&bm);
let backend: Box<dyn OcrBackend> = Box::new(MockBackend);
let a = ocr_text(backend.as_ref(), &pm).unwrap();
let b = ocr_text(backend.as_ref(), &pm).unwrap();
assert_eq!(char_accuracy(&a, &b), 1.0);
assert_eq!(word_accuracy(&a, &b), 1.0);
}
#[test]
fn mock_backend_detects_pixel_change() {
let blank = Bitmap::new(8, 8);
let mut one_pixel = Bitmap::new(8, 8);
one_pixel.set(0, 0, true);
let backend: Box<dyn OcrBackend> = Box::new(MockBackend);
let a = ocr_text(backend.as_ref(), &mask_to_pixmap(&blank)).unwrap();
let b = ocr_text(backend.as_ref(), &mask_to_pixmap(&one_pixel)).unwrap();
assert_ne!(a, b, "mock backend text should reflect the pixel change");
}
#[test]
fn build_backend_without_feature_is_none() {
let _ = build_backend();
}
}