use std::collections::HashMap;
use anyhow::{Context, Result};
use crate::finding::{Category, Finding, Severity};
use crate::ocr::{GrayImage, OcrEngine, OcrHint};
const PX: f32 = 72.0; const PAD: f32 = 12.0; const GAP: f32 = 16.0; const REPEATS: usize = 6; const MIN_CONF: f32 = 65.0;
fn is_ligature(c: char) -> bool {
matches!(c as u32, 0xFB00..=0xFB4F)
}
struct PathPen {
pb: tiny_skia::PathBuilder,
scale: f32,
x_min: f32,
y_max: f32,
}
impl PathPen {
fn ix(&self, x: f32) -> f32 {
(x - self.x_min) * self.scale + PAD
}
fn iy(&self, y: f32) -> f32 {
(self.y_max - y) * self.scale + PAD
}
}
impl ttf_parser::OutlineBuilder for PathPen {
fn move_to(&mut self, x: f32, y: f32) {
self.pb.move_to(self.ix(x), self.iy(y));
}
fn line_to(&mut self, x: f32, y: f32) {
self.pb.line_to(self.ix(x), self.iy(y));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.pb.quad_to(self.ix(x1), self.iy(y1), self.ix(x), self.iy(y));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.pb.cubic_to(self.ix(x1), self.iy(y1), self.ix(x2), self.iy(y2), self.ix(x), self.iy(y));
}
fn close(&mut self) {
self.pb.close();
}
}
fn render_specimen(face: &ttf_parser::Face, gid: u16) -> Option<GrayImage> {
let bbox = face.glyph_bounding_box(ttf_parser::GlyphId(gid))?;
let upem = face.units_per_em() as f32;
if upem <= 0.0 {
return None;
}
let scale = PX / upem;
let gw = (bbox.x_max - bbox.x_min) as f32 * scale;
let gh = (bbox.y_max - bbox.y_min) as f32 * scale;
if gw < 1.0 || gh < 1.0 {
return None;
}
let mut pen = PathPen {
pb: tiny_skia::PathBuilder::new(),
scale,
x_min: bbox.x_min as f32,
y_max: bbox.y_max as f32,
};
face.outline_glyph(ttf_parser::GlyphId(gid), &mut pen)?;
let path = pen.pb.finish()?;
let step = gw + GAP;
let w = (PAD * 2.0 + gw + (REPEATS as f32 - 1.0) * step).ceil() as u32;
let h = (gh + PAD * 2.0).ceil() as u32;
let mut pm = tiny_skia::Pixmap::new(w.max(1), h.max(1))?;
pm.fill(tiny_skia::Color::WHITE);
let mut paint = tiny_skia::Paint::default();
paint.set_color(tiny_skia::Color::BLACK);
paint.anti_alias = true;
for k in 0..REPEATS {
let dx = k as f32 * step;
pm.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::from_translate(dx, 0.0),
None,
);
}
let gray: Vec<u8> = pm.data().chunks_exact(4).map(|px| px[0]).collect();
Some(GrayImage::new(w, h, gray))
}
fn vote(scored: &[(char, f32)]) -> Option<char> {
let mut counts: HashMap<char, usize> = HashMap::new();
let mut total = 0usize;
for &(c, conf) in scored {
if conf < MIN_CONF || !c.is_alphabetic() {
continue;
}
if let Some(l) = c.to_lowercase().next() {
*counts.entry(l).or_default() += 1;
total += 1;
}
}
let (top, n) = counts.into_iter().max_by_key(|&(_, n)| n)?;
(n * 2 > total).then_some(top) }
pub fn specimen_scan(
fonts: &[crate::FontClaims],
ocr: &dyn OcrEngine,
rule: &str,
label: &str,
) -> Result<Vec<Finding>> {
let mut lies: HashMap<(char, char), usize> = HashMap::new();
for (bytes, claims) in fonts {
let Ok(face) = ttf_parser::Face::parse(bytes, 0) else { continue };
let mut by_gid: HashMap<u16, Vec<char>> = HashMap::new();
for &(ch, gid) in claims {
if ch.is_alphabetic() && !is_ligature(ch) {
by_gid.entry(gid).or_default().push(ch);
}
}
for (gid, claimed) in by_gid {
let Some(img) = render_specimen(&face, gid) else { continue };
let scored: Vec<(char, f32)> = ocr
.recognize_scored(&img, OcrHint::SingleLine)
.unwrap_or_default()
.into_iter()
.filter_map(|(w, conf)| w.chars().find(|c| c.is_alphabetic()).map(|c| (c, conf)))
.collect();
let Some(drawn) = vote(&scored) else { continue }; for ch in claimed {
if !crate::glyphmatch::legitimately_identical_ci(drawn, ch) {
let extracted = ch.to_lowercase().next().unwrap_or(ch);
*lies.entry((drawn, extracted)).or_default() += 1;
}
}
}
}
let mut findings: Vec<Finding> = lies
.into_iter()
.map(|((drawn, extracted), n)| {
Finding::new(
rule,
Severity::High,
Category::FontIntegrity,
label,
format!(
"a glyph that OCRs as '{drawn}' is extracted as '{extracted}' ({n}×): the embedded font draws a different letter than it reports (semantic replacement confirmed by specimen OCR)"
),
0.8,
)
})
.collect();
findings.sort_by(|a, b| a.message.cmp(&b.message));
Ok(findings)
}
pub fn specimen_scan_path(path: &std::path::Path, ocr: &dyn OcrEngine) -> Result<Vec<Finding>> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let label = "embedded font";
match ext.as_str() {
"pdf" => {
let fonts = crate::pdf_glyph::pdf_font_claims(path).context("extracting PDF font claims")?;
specimen_scan(&fonts, ocr, "PDF.GLYPH_SEMANTIC_REPLACEMENT_OCR", label)
}
"docx" => {
let fonts =
crate::docx_glyph::docx_font_claims(path).context("extracting DOCX font claims")?;
specimen_scan(&fonts, ocr, "DOCX.GLYPH_SEMANTIC_REPLACEMENT_OCR", label)
}
other => anyhow::bail!("specimen scan unsupported for .{other} (pdf, docx only)"),
}
}
#[cfg(test)]
mod tests {
use super::{is_ligature, vote, MIN_CONF};
use crate::glyphmatch::legitimately_identical_ci as legitimately_identical;
#[test]
fn ligatures_are_excluded() {
assert!(is_ligature('\u{FB00}')); assert!(is_ligature('\u{FB03}')); assert!(is_ligature('\u{FB04}')); assert!(!is_ligature('a'));
assert!(!is_ligature('\u{00E9}')); }
#[test]
fn homoglyphs_and_real_swaps() {
assert!(legitimately_identical('a', 'a'));
assert!(legitimately_identical('M', 'm')); assert!(legitimately_identical('i', 'l')); assert!(legitimately_identical('a', '\u{0430}')); assert!(legitimately_identical('b', '\u{03B2}')); assert!(legitimately_identical('z', '\u{03B6}')); assert!(legitimately_identical('\u{00F0}', '\u{0111}')); assert!(legitimately_identical('\u{00F0}', '\u{0256}')); assert!(!legitimately_identical('m', 'd')); assert!(!legitimately_identical('r', 'n'));
}
#[test]
fn vote_needs_confident_strict_majority() {
let hi = MIN_CONF + 10.0;
let lo = MIN_CONF - 10.0;
assert_eq!(vote(&[('m', hi)]), Some('m')); assert_eq!(vote(&[]), None); assert_eq!(vote(&[('c', lo), ('c', lo)]), None); assert_eq!(vote(&[('m', hi), ('m', hi), ('x', hi)]), Some('m')); assert_eq!(vote(&[('m', hi), ('d', hi)]), None); assert_eq!(vote(&[('m', hi), ('d', lo)]), Some('m')); }
}