use std::path::Path;
use anyhow::{anyhow, Result};
use pdfium_render::prelude::*;
use crate::finding::{Category, Finding, Report, Severity, Verdict};
use crate::ocr::{GrayImage, OcrEngine, OcrHint};
use crate::textdiff::{jaccard, words};
pub use crate::textdiff::significant_substitutions;
pub struct PageDivergence {
pub page: usize,
pub extracted: String,
pub visual: String,
pub similarity: f32,
}
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)
}
pub fn verify_with_render(report: &mut Report, pdf: &Path, pdfium_dir: &Path, ocr: &dyn OcrEngine) -> Result<()> {
let has_collision = report.findings.iter().any(|f| f.rule.contains("GLYPH_SEMANTIC_REPLACEMENT"));
let has_hidden_candidate = report.findings.iter().any(|f| {
matches!(f.category, Category::HiddenContent)
&& (f.rule.contains("INVISIBLE") || f.rule.contains("TINY") || f.rule.contains("OFFPAGE"))
});
if !has_collision && report.phrases.is_empty() && !has_hidden_candidate {
return Ok(());
}
let pages = ocr_vs_extracted(pdf, pdfium_dir, ocr, 2.0, 50)?;
if has_hidden_candidate {
let mut hidden: Vec<String> = Vec::new();
for p in &pages {
for w in crate::textdiff::missing_words(&p.extracted, &p.visual) {
if !hidden.contains(&w) {
hidden.push(w);
}
}
}
if hidden.len() >= 4 {
let list = hidden.iter().take(20).cloned().collect::<Vec<_>>().join(" ");
report.push(Finding::new(
"PDF.HIDDEN_TEXT_CONFIRMED",
Severity::High,
Category::HiddenContent,
"rendered page",
format!("render-OCR confirms {} word(s) in the extract but not visible on the page: {list}", hidden.len()),
0.85,
));
}
}
let ocr_text: String = pages.iter().map(|p| p.visual.as_str()).collect::<Vec<_>>().join("\n");
let ocr_sents = crate::finding::split_sentences(&ocr_text);
for ph in &mut report.phrases {
let cand = ph.presumed.clone().unwrap_or_default();
let best = ocr_sents.iter().max_by(|a, b| {
let sa = jaccard(&cand, a).max(jaccard(&ph.extracted, a));
let sb = jaccard(&cand, b).max(jaccard(&ph.extracted, b));
sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(s) = best {
if jaccard(&cand, s).max(jaccard(&ph.extracted, s)) >= 0.4 {
ph.ocr = Some(s.clone());
}
}
}
if !has_collision {
return Ok(());
}
let subs: Vec<(String, String)> =
pages.iter().flat_map(|p| significant_substitutions(&p.extracted, &p.visual)).collect();
let mean_sim =
if pages.is_empty() { 0.0 } else { pages.iter().map(|p| p.similarity).sum::<f32>() / pages.len() as f32 };
if !subs.is_empty() {
report.verdict = Some(Verdict::Confirmed);
let list: String =
subs.iter().take(10).map(|(e, v)| format!("'{e}'→'{v}'")).collect::<Vec<_>>().join(", ");
report.push(Finding::new(
"PDF.RENDER_DIVERGENCE_CONFIRMED",
Severity::High,
Category::FontIntegrity,
"rendered page",
format!("OCR confirms the rendered text diverges from the extracted text: {list}"),
0.9,
));
} else {
let evidence: Vec<(&str, &str, &str)> = report
.phrases
.iter()
.filter_map(|ph| Some((ph.extracted.as_str(), ph.presumed.as_deref()?, ph.ocr.as_deref()?)))
.collect();
let by_phrase = crate::textdiff::ocr_refutes_substitutions(&evidence);
let reason = if mean_sim >= 0.9 {
Some("the rendered page matches the extracted text")
} else if by_phrase {
Some("the rendered text of every affected sentence matches what was extracted")
} else {
None
};
if let Some(reason) = reason {
report.verdict = Some(Verdict::Refuted);
for f in report.findings.iter_mut().filter(|f| f.rule.contains("GLYPH_SEMANTIC_REPLACEMENT")) {
f.severity = Severity::Info;
f.message.push_str(&format!(
" — OCR-refuted: {reason} (the font carries a glyph collision but it is not used on the visible text)"
));
}
} }
report.withdraw_uncorroborated_presumed();
Ok(())
}
pub fn diverges(d: &PageDivergence, threshold: f32) -> bool {
words(&d.visual).len() >= 5 && d.similarity < threshold
}