use std::collections::{HashMap, HashSet};
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, Report, Severity};
use crate::registry::FontRegistry;
use crate::{font, scan};
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());
static ARR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*\[([^\]]*)\]").unwrap());
static TOK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([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_of(o: &Object) -> Option<String> {
match o {
Object::Name(b) => Some(String::from_utf8_lossy(b).into_owned()),
_ => None,
}
}
fn font_descriptor<'a>(doc: &'a Document, fontdict: &'a Dictionary) -> Option<&'a Dictionary> {
if let Some(fd) = dget(doc, fontdict, b"FontDescriptor").and_then(as_dict) {
return Some(fd);
}
if let Some(Object::Array(arr)) = dget(doc, fontdict, b"DescendantFonts") {
if let Some(first) = arr.first().and_then(|o| deref(doc, o)).and_then(as_dict) {
return dget(doc, first, b"FontDescriptor").and_then(as_dict);
}
}
None
}
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(bytes) = s.decompressed_content() {
return Some(bytes);
}
}
}
None
}
fn is_unreadable_cp(cp: u32) -> bool {
font::is_pua(cp) || matches!(cp, 0xFEFF | 0x00AD | 0x200B..=0x200D | 0x2060..=0x2064)
}
fn pua_units(hex: &str) -> usize {
let mut n = 0;
for chunk in hex.as_bytes().chunks(4) {
if chunk.len() == 4 {
if let Ok(cp) = u32::from_str_radix(std::str::from_utf8(chunk).unwrap_or(""), 16) {
if is_unreadable_cp(cp) {
n += 1;
}
}
}
}
n
}
fn tounicode_pua_count(doc: &Document, fontdict: &Dictionary) -> usize {
let Some(Object::Stream(s)) = dget(doc, fontdict, b"ToUnicode") else {
return 0;
};
let Ok(content) = s.decompressed_content() else {
return 0;
};
let text = String::from_utf8_lossy(&content);
let mut pua = 0usize;
for blk in BFCHAR_BLK.captures_iter(&text) {
for c in PAIR.captures_iter(&blk[1]) {
pua += pua_units(&c[2]);
}
}
for blk in BFRANGE_BLK.captures_iter(&text) {
let body = &blk[1];
for c in TRIPLE.captures_iter(body) {
pua += pua_units(&c[3]);
}
for c in ARR.captures_iter(body) {
for t in TOK.captures_iter(&c[3]) {
pua += pua_units(&t[1]);
}
}
}
pua
}
fn is_math_or_symbol_font(name: &str) -> bool {
let n = name.to_ascii_lowercase();
["cmex", "cmsy", "cmmi", "msam", "msbm", "stmary", "wasy", "symbol", "wingding", "webding", "dingbat"]
.iter()
.any(|s| n.contains(s))
}
fn subset_parts(base: &str) -> (Option<&str>, &str) {
if base.len() > 7 && base.as_bytes().get(6) == Some(&b'+') {
(Some(&base[..6]), &base[7..])
} else {
(None, base)
}
}
pub fn scan(path: &Path, registry: Option<&FontRegistry>) -> Result<Report> {
let doc = Document::load(path).with_context(|| format!("opening PDF {}", path.display()))?;
let mut report = Report::new(&path.display().to_string(), "pdf");
let meta = crate::metadata::pdf_metadata(&doc);
if !meta.is_empty() {
report.metadata = Some(meta);
}
let mut subset_families: HashMap<String, HashSet<String>> = HashMap::new();
let mut examined = 0usize;
for obj in doc.objects.values() {
let Some(d) = as_dict(obj) else { continue };
if dget(&doc, d, b"Type").and_then(name_of).as_deref() != Some("Font") {
continue;
}
let base = dget(&doc, d, b"BaseFont").and_then(name_of).unwrap_or_else(|| "(no BaseFont)".into());
let loc = format!("font {base}");
examined += 1;
let (tag, family) = subset_parts(&base);
if let Some(tag) = tag {
subset_families.entry(family.to_string()).or_default().insert(tag.to_string());
}
const GARBLED_MIN: usize = 4;
let garbled = tounicode_pua_count(&doc, d);
if garbled >= GARBLED_MIN && !is_math_or_symbol_font(family) {
report.push(Finding::new(
"PDF.TOUNICODE_GARBLED",
Severity::High,
Category::FontIntegrity,
&loc,
format!("{garbled} ToUnicode mapping(s) to the Private Use Area or invisible/zero-width codepoints: the extracted text will be garbled while the glyphs render normally"),
0.85,
));
}
if let Some(enc) = dget(&doc, d, b"Encoding").and_then(as_dict) {
if enc.get(b"Differences").is_ok() {
report.push(Finding::new(
"PDF.CUSTOM_DIFFERENCES",
Severity::Info,
Category::Structural,
&loc,
"encoding with /Differences: custom code remapping (legitimate, but it is the vector for glyph↔codepoint redirects)",
0.3,
));
}
}
if let Some(bytes) = embedded_font_bytes(&doc, d) {
for parsed in font::parse_data(&bytes) {
scan::judge_embedded_font(&parsed, &loc, registry, &mut report.findings);
}
}
}
for (family, tags) in &subset_families {
if tags.len() >= 5 {
report.push(Finding::new(
"PDF.MANY_SUBSETS",
Severity::Medium,
Category::Structural,
format!("family {family}"),
format!("{} distinct subsets of the same family: possible per-page/per-run dynamic font subsetting (variant B)", tags.len()),
0.6,
));
}
}
{
let outline = crate::pdf_glyph::pdf_outline_scan_doc(&doc);
let mut subs = outline.substitutions;
if !outline.findings.is_empty() {
report.verdict = Some(crate::finding::Verdict::Unconfirmed);
if let Some(reg) = registry {
{
let csubs = crate::pdf_glyph::pdf_canonical_substitutions_doc(&doc, reg);
if !csubs.is_empty() {
report.verdict = Some(crate::finding::Verdict::Confirmed);
let list = csubs.iter().take(6).map(|(e, d)| format!("'{e}'→'{d}'")).collect::<Vec<_>>().join(", ");
report.push(Finding::new(
"PDF.CANONICAL_TAMPER_CONFIRMED",
Severity::High,
Category::FontIntegrity,
"embedded font",
format!("registry outline check: {} embedded glyph(s) draw a different letter than the extracted character ({list})", csubs.len()),
0.95,
));
subs = csubs; }
}
}
}
if !subs.is_empty() {
let mut phrases = Vec::new();
for pg in doc.get_pages().keys().copied() {
if phrases.len() >= 100 {
break;
}
if let Ok(raw) = doc.extract_text(&[pg]) {
phrases.extend(crate::finding::phrase_diffs_paged(&raw, &subs, Some(pg)));
}
}
phrases.truncate(100);
report.phrases = phrases;
}
report.findings.extend(outline.findings);
}
report.findings.extend(crate::visibility::pdf_visibility_scan(&doc));
report.fonts_examined = examined;
Ok(report)
}