use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use lopdf::{Dictionary, Document, Object};
use regex::Regex;
use ttf_parser::GlyphId;
use crate::finding::{Category, Finding, OutlineScan, Severity};
fn deref<'a>(doc: &'a Document, o: &'a Object) -> Option<&'a Object> {
match o {
Object::Reference(id) => doc.get_object(*id).ok(),
x => Some(x),
}
}
fn dget<'a>(doc: &'a Document, d: &'a Dictionary, k: &[u8]) -> Option<&'a Object> {
d.get(k).ok().and_then(|o| deref(doc, o))
}
fn as_dict(o: &Object) -> Option<&Dictionary> {
match o {
Object::Dictionary(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
}
}
fn name_is(o: &Object, want: &str) -> bool {
matches!(o, Object::Name(b) if b == want.as_bytes())
}
fn descendant<'a>(doc: &'a Document, fontdict: &'a Dictionary) -> Option<&'a Dictionary> {
if let Some(Object::Array(arr)) = dget(doc, fontdict, b"DescendantFonts") {
return arr.first().and_then(|o| deref(doc, o)).and_then(as_dict);
}
Some(fontdict)
}
fn font_descriptor<'a>(doc: &'a Document, fontdict: &'a Dictionary) -> Option<&'a Dictionary> {
let d = descendant(doc, fontdict).unwrap_or(fontdict);
dget(doc, d, b"FontDescriptor").and_then(as_dict)
}
fn embedded_font_bytes(doc: &Document, fontdict: &Dictionary) -> Option<Vec<u8>> {
let fd = font_descriptor(doc, fontdict)?;
for key in [b"FontFile2".as_ref(), b"FontFile3".as_ref(), b"FontFile".as_ref()] {
if let Some(Object::Stream(s)) = dget(doc, fd, key) {
if let Ok(b) = s.decompressed_content() {
return Some(b);
}
}
}
None
}
enum CidGid {
Identity,
Map(Vec<u16>),
}
impl CidGid {
fn gid(&self, code: u32) -> Option<u16> {
match self {
CidGid::Identity => u16::try_from(code).ok(),
CidGid::Map(m) => m.get(code as usize).copied().filter(|&g| g != 0),
}
}
}
fn is_type0(doc: &Document, fontdict: &Dictionary) -> bool {
matches!(dget(doc, fontdict, b"DescendantFonts"), Some(Object::Array(_)))
}
fn cid_to_gid(doc: &Document, fontdict: &Dictionary) -> CidGid {
let Some(d) = descendant(doc, fontdict) else { return CidGid::Identity };
match dget(doc, d, b"CIDToGIDMap") {
Some(Object::Stream(s)) => match s.decompressed_content() {
Ok(bytes) => {
CidGid::Map(bytes.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect())
}
Err(_) => CidGid::Identity,
},
_ => CidGid::Identity, }
}
fn parse_tounicode(doc: &Document, fontdict: &Dictionary) -> HashMap<u32, char> {
let mut map = HashMap::new();
let Some(Object::Stream(s)) = dget(doc, fontdict, b"ToUnicode") else { return map };
let Ok(content) = s.decompressed_content() else { return map };
let text = String::from_utf8_lossy(&content);
let hex_char = |h: &str| -> Option<char> {
let v = u32::from_str_radix(h.get(0..4)?, 16).ok()?;
char::from_u32(v)
};
let bfchar_blk = Regex::new(r"(?s)beginbfchar(.*?)endbfchar").unwrap();
let pair = Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap();
for blk in bfchar_blk.captures_iter(&text) {
for c in pair.captures_iter(&blk[1]) {
if let (Ok(code), Some(ch)) = (u32::from_str_radix(&c[1], 16), hex_char(&c[2])) {
map.insert(code, ch);
}
}
}
let bfrange_blk = Regex::new(r"(?s)beginbfrange(.*?)endbfrange").unwrap();
let triple = Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap();
for blk in bfrange_blk.captures_iter(&text) {
for c in triple.captures_iter(&blk[1]) {
if let (Ok(lo), Ok(hi), Some(startc)) =
(u32::from_str_radix(&c[1], 16), u32::from_str_radix(&c[2], 16), hex_char(&c[3]))
{
let start = startc as u32;
for (k, code) in (lo..=hi.min(lo + 4096)).enumerate() {
if let Some(ch) = char::from_u32(start + k as u32) {
map.insert(code, ch);
}
}
}
}
}
map
}
struct OutlineSig {
h: u64,
empty: bool,
}
impl OutlineSig {
fn new() -> Self {
Self { h: 0xcbf29ce484222325, empty: true }
}
fn mix(&mut self, v: i64) {
self.empty = false;
self.h ^= v as u64;
self.h = self.h.wrapping_mul(0x100000001b3);
}
}
impl ttf_parser::OutlineBuilder for OutlineSig {
fn move_to(&mut self, x: f32, y: f32) {
self.mix(1);
self.mix(x as i64);
self.mix(y as i64);
}
fn line_to(&mut self, x: f32, y: f32) {
self.mix(2);
self.mix(x as i64);
self.mix(y as i64);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.mix(3);
for v in [x1, y1, x, y] {
self.mix(v as i64);
}
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.mix(4);
for v in [x1, y1, x2, y2, x, y] {
self.mix(v as i64);
}
}
fn close(&mut self) {
self.mix(5);
}
}
fn glyph_sig(face: &ttf_parser::Face, gid: u16) -> Option<u64> {
let mut b = OutlineSig::new();
face.outline_glyph(GlyphId(gid), &mut b)?;
(!b.empty).then_some(b.h)
}
fn letter(cp: u32) -> Option<char> {
use unicode_script::{Script, UnicodeScript};
let c = char::from_u32(cp)?;
if c.is_alphabetic() && c.script() == Script::Latin {
c.to_lowercase().next()
} else {
None
}
}
fn skel(c: char) -> String {
unicode_security::confusable_detection::skeleton(&c.to_string()).collect()
}
fn legitimately_identical(a: char, b: char) -> bool {
use unicode_normalization::UnicodeNormalization;
use unicode_script::UnicodeScript;
if a == b {
return true;
}
if a.script() != b.script() {
return true;
}
if a.to_string().nfkd().eq(b.to_string().nfkd()) {
return true;
}
if let (Some(x), Some(y)) = (deunicode::deunicode_char(a), deunicode::deunicode_char(b)) {
if !x.is_empty() && x == y {
return true;
}
}
let mut p = [a, b];
p.sort_unstable();
if matches!((p[0], p[1]), ('i', 'l')) {
return true;
}
skel(a) == skel(b)
}
pub fn pdf_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
use std::collections::HashSet;
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
let mut out: Vec<crate::FontClaims> = Vec::new();
for obj in doc.objects.values() {
let Some(d) = as_dict(obj) else { continue };
if dget(&doc, d, b"Type").map(|o| name_is(o, "Font")) != Some(true) {
continue;
}
let Some(bytes) = embedded_font_bytes(&doc, d) else { continue };
if ttf_parser::Face::parse(&bytes, 0).is_err() {
continue;
}
let mut claims: HashSet<(char, u16)> = HashSet::new();
if is_type0(&doc, d) {
let cidgid = cid_to_gid(&doc, d);
for (code, ch) in parse_tounicode(&doc, d) {
if let Some(gid) = cidgid.gid(code) {
claims.insert((ch, gid));
}
}
}
if let Ok(face) = ttf_parser::Face::parse(&bytes, 0) {
if let Some(cmap) = face.tables().cmap {
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
if let (Some(c), Some(gid)) = (char::from_u32(cp), sub.glyph_index(cp)) {
claims.insert((c, gid.0));
}
});
}
}
}
if !claims.is_empty() {
out.push((bytes, claims.into_iter().collect()));
}
}
Ok(out)
}
pub fn pdf_canonical_substitutions(path: &Path, registry: &crate::registry::FontRegistry) -> Result<Vec<(char, char)>> {
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
let mut out = Vec::new();
for obj in doc.objects.values() {
let Some(d) = as_dict(obj) else { continue };
if dget(&doc, d, b"Type").map(|o| name_is(o, "Font")) != Some(true) {
continue;
}
let Some(bytes) = embedded_font_bytes(&doc, d) else { continue };
for pf in crate::font::parse_data(&bytes) {
out.extend(registry.canonical_substitutions(&pf));
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
pub fn pdf_outline_scan(path: &Path) -> Result<OutlineScan> {
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
let mut sigs: HashMap<u64, HashMap<char, usize>> = HashMap::new();
for obj in doc.objects.values() {
let Some(d) = as_dict(obj) else { continue };
if dget(&doc, d, b"Type").map(|o| name_is(o, "Font")) != Some(true) {
continue;
}
let Some(bytes) = embedded_font_bytes(&doc, d) else { continue };
let Ok(face) = ttf_parser::Face::parse(&bytes, 0) else { continue };
if is_type0(&doc, d) {
let cidgid = cid_to_gid(&doc, d);
for (code, ch) in parse_tounicode(&doc, d) {
let Some(letter) = letter(ch as u32) else { continue };
if let Some(gid) = cidgid.gid(code) {
if let Some(h) = glyph_sig(&face, gid) {
*sigs.entry(h).or_default().entry(letter).or_default() += 1;
}
}
}
}
if let Some(cmap) = face.tables().cmap {
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
let Some(letter) = letter(cp) else { return };
if let Some(gid) = sub.glyph_index(cp) {
if let Some(h) = glyph_sig(&face, gid.0) {
*sigs.entry(h).or_default().entry(letter).or_default() += 1;
}
}
});
}
}
}
let mut lies: HashMap<(char, char), usize> = HashMap::new();
for tally in sigs.values() {
if tally.len() < 2 {
continue;
}
let mut letters: Vec<(char, usize)> = tally.iter().map(|(c, n)| (*c, *n)).collect();
letters.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
let (truth, _) = letters[0];
for (lie, n) in letters.iter().skip(1) {
if legitimately_identical(truth, *lie) {
continue;
}
*lies.entry((truth, *lie)).or_default() += *n;
}
}
let mut best: HashMap<char, (char, usize)> = HashMap::new();
for (&(truth, lie), &n) in &lies {
let e = best.entry(lie).or_insert((truth, 0));
if n > e.1 {
*e = (truth, n);
}
}
let mut substitutions: Vec<(char, char)> = best.into_iter().map(|(lie, (truth, _))| (lie, truth)).collect();
substitutions.sort_unstable();
let mut findings: Vec<Finding> = lies
.into_iter()
.map(|((truth, lie), n)| {
Finding::new(
"PDF.GLYPH_SEMANTIC_REPLACEMENT",
Severity::High,
Category::FontIntegrity,
"embedded font",
format!(
"a glyph that draws '{truth}' is mapped from the extracted character '{lie}' ({n}×): the embedded font lies (semantic replacement / variant A3)"
),
0.9,
)
})
.collect();
findings.sort_by(|a, b| a.message.cmp(&b.message));
Ok(OutlineScan { findings, substitutions })
}
#[cfg(test)]
mod tests {
use super::CidGid;
#[test]
fn cidgid_identity_is_code() {
let m = CidGid::Identity;
assert_eq!(m.gid(65), Some(65));
assert_eq!(m.gid(0), Some(0));
assert_eq!(m.gid(70_000), None); }
#[test]
fn cidgid_stream_map_resolves_and_filters() {
let m = CidGid::Map(vec![0, 5, 300]);
assert_eq!(m.gid(0), None); assert_eq!(m.gid(1), Some(5));
assert_eq!(m.gid(2), Some(300));
assert_eq!(m.gid(9), None); }
}