use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use anyhow::{Context, Result};
use lopdf::{Dictionary, Document, Object};
use regex::Regex;
use crate::finding::{Category, Finding, OutlineScan, Severity};
static BFCHAR_BLK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)beginbfchar(.*?)endbfchar").unwrap());
static BFRANGE_BLK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)beginbfrange(.*?)endbfrange").unwrap());
static PAIR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap());
static TRIPLE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>").unwrap());
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),
}
}
}
pub(crate) 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, }
}
pub(crate) 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)
};
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);
}
}
}
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
}
use crate::font::glyph_outline_hash;
use crate::glyphmatch::{legitimately_identical, letter_latin as letter};
pub fn pdf_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
Ok(pdf_font_claims_doc(&doc))
}
pub fn pdf_font_claims_doc(doc: &Document) -> Vec<crate::FontClaims> {
use std::collections::HashSet;
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()));
}
}
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()))?;
Ok(pdf_canonical_substitutions_doc(&doc, registry))
}
pub fn pdf_canonical_substitutions_doc(doc: &Document, registry: &crate::registry::FontRegistry) -> Vec<(char, char)> {
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();
out
}
pub fn pdf_outline_scan(path: &Path) -> Result<OutlineScan> {
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
Ok(pdf_outline_scan_doc(&doc))
}
pub fn pdf_outline_scan_doc(doc: &Document) -> OutlineScan {
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_outline_hash(&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_outline_hash(&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));
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); }
}