use std::collections::HashSet;
use std::path::Path;
use anyhow::{anyhow, Result};
use pdfium_render::prelude::*;
use crate::ocr::{GrayImage, OcrEngine, OcrHint};
pub struct PageDivergence {
pub page: usize,
pub extracted: String,
pub visual: String,
pub similarity: f32,
}
fn words(s: &str) -> HashSet<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() >= 2)
.map(|w| w.to_string())
.collect()
}
fn jaccard(a: &str, b: &str) -> f32 {
let (wa, wb) = (words(a), words(b));
if wa.is_empty() && wb.is_empty() {
return 1.0;
}
let inter = wa.intersection(&wb).count() as f32;
let union = wa.union(&wb).count() as f32;
if union == 0.0 {
0.0
} else {
inter / union
}
}
pub fn ocr_vs_extracted(
pdf: &Path,
pdfium_dir: &Path,
ocr: &dyn OcrEngine,
scale: f32,
max_pages: usize,
) -> Result<Vec<PageDivergence>> {
let lib = Pdfium::pdfium_platform_library_name_at_path(pdfium_dir);
let bindings =
Pdfium::bind_to_library(&lib).map_err(|e| anyhow!("bind pdfium ({}): {e}", lib.display()))?;
let pdfium = Pdfium::new(bindings);
let doc = pdfium.load_pdf_from_file(pdf, None).map_err(|e| anyhow!("open pdf: {e}"))?;
let mut out = Vec::new();
for (i, page) in doc.pages().iter().enumerate() {
if i >= max_pages {
break;
}
let w_pt = page.width().value;
let target_w = ((w_pt * scale).round() as i32).max(1);
let config = PdfRenderConfig::new().set_target_width(target_w);
let bitmap = page.render_with_config(&config).map_err(|e| anyhow!("render page: {e}"))?;
let luma = bitmap.as_image().to_luma8();
let (w, h) = (luma.width(), luma.height());
let img = GrayImage::new(w, h, luma.into_raw());
let visual = ocr.recognize(&img, OcrHint::Block)?;
let extracted = page.text().map(|t| t.all()).unwrap_or_default();
let similarity = jaccard(&extracted, &visual);
out.push(PageDivergence { page: i + 1, extracted, visual, similarity });
}
Ok(out)
}
fn word_seq(s: &str) -> Vec<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() >= 2)
.map(|w| w.to_string())
.collect()
}
pub fn word_mismatches(extracted: &str, visual: &str) -> Vec<(String, String)> {
let a = word_seq(extracted);
let b = word_seq(visual);
let (n, m) = (a.len(), b.len());
if n == 0 || m == 0 {
return Vec::new();
}
let mut dp = vec![vec![0u16; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if a[i] == b[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
let (mut i, mut j) = (0usize, 0usize);
let (mut dels, mut inss): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
let mut out = Vec::new();
let flush = |dels: &mut Vec<String>, inss: &mut Vec<String>, out: &mut Vec<(String, String)>| {
let k = dels.len().min(inss.len());
for t in 0..k {
out.push((dels[t].clone(), inss[t].clone()));
}
dels.clear();
inss.clear();
};
while i < n && j < m {
if a[i] == b[j] {
flush(&mut dels, &mut inss, &mut out);
i += 1;
j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
dels.push(a[i].clone());
i += 1;
} else {
inss.push(b[j].clone());
j += 1;
}
}
while i < n {
dels.push(a[i].clone());
i += 1;
}
while j < m {
inss.push(b[j].clone());
j += 1;
}
flush(&mut dels, &mut inss, &mut out);
out
}
fn levenshtein(a: &[char], b: &[char]) -> usize {
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
}
prev = cur;
}
prev[b.len()]
}
fn is_real_substitution(e: &str, v: &str) -> bool {
let (ec, vc): (Vec<char>, Vec<char>) = (e.chars().collect(), v.chars().collect());
if ec.len() < 3 || vc.len() < 3 || !ec.iter().all(|c| c.is_alphabetic()) || !vc.iter().all(|c| c.is_alphabetic()) {
return false;
}
let max = ec.len().max(vc.len()) as f32;
levenshtein(&ec, &vc) as f32 / max > 0.4
}
pub fn significant_substitutions(extracted: &str, visual: &str) -> Vec<(String, String)> {
word_mismatches(extracted, visual)
.into_iter()
.filter(|(e, v)| is_real_substitution(e, v))
.collect()
}
pub fn diverges(d: &PageDivergence, threshold: f32) -> bool {
words(&d.visual).len() >= 5 && d.similarity < threshold
}