use skrifa::raw::FontRef;
use skrifa::MetadataProvider;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::Path;
use std::sync::LazyLock;
use anyhow::{Context, Result};
use regex::Regex;
use crate::finding::{Category, Finding, OutlineScan, Severity};
static FONTKEY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap());
static WT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<w:t[ >](.*?)</w:t>").unwrap());
pub(crate) fn deobfuscate(odttf: &[u8], guid: &str, reversed: bool) -> Vec<u8> {
let hexs: String = guid.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if hexs.len() != 32 {
return odttf.to_vec();
}
let mut key = [0u8; 16];
for i in 0..16 {
key[i] = u8::from_str_radix(&hexs[i * 2..i * 2 + 2], 16).unwrap_or(0);
}
let mut out = odttf.to_vec();
let n = out.len().min(32);
for i in 0..n {
let kb = if reversed { key[15 - (i % 16)] } else { key[i % 16] };
out[i] ^= kb;
}
out
}
use crate::font::glyph_outline_hash;
use crate::glyphmatch::{legitimately_identical, letter_latin as letter};
#[derive(Default)]
struct Tally {
by_letter: HashMap<char, usize>,
}
fn document_letters(zip: &mut zip::ZipArchive<std::fs::File>, names: &[String]) -> HashSet<char> {
let mut set = HashSet::new();
for name in names {
let textpart = name == "word/document.xml"
|| (name.starts_with("word/header") && name.ends_with(".xml"))
|| (name.starts_with("word/footer") && name.ends_with(".xml"))
|| name == "word/footnotes.xml"
|| name == "word/endnotes.xml";
if !textpart {
continue;
}
let mut s = String::new();
if zip.by_name(name).map(|mut zf| zf.read_to_string(&mut s)).is_err() {
continue;
}
for cap in WT_RE.captures_iter(&s) {
for c in cap[1].chars() {
if c.is_alphabetic() {
if let Some(l) = c.to_lowercase().next() {
set.insert(l);
}
}
}
}
}
set
}
pub fn docx_canonical_substitutions(path: &Path, registry: &crate::registry::FontRegistry) -> Result<Vec<(char, char)>> {
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
let mut out = Vec::new();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
let faces = crate::font::parse_data(&de);
if !faces.is_empty() {
for pf in &faces {
out.extend(registry.canonical_substitutions(pf));
}
break 'keys;
}
}
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
pub fn docx_outline_scan(path: &Path) -> Result<OutlineScan> {
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let mut sigs: HashMap<u64, Tally> = HashMap::new();
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
let mut parsed = false;
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
if let Ok(face) = FontRef::new(&de) {
tally_face(&face, &doc_letters, &mut sigs);
parsed = true;
break 'keys;
}
}
}
let _ = parsed;
}
let mut lies: HashMap<(char, char), usize> = HashMap::new();
for tally in sigs.values() {
if tally.by_letter.len() < 2 {
continue;
}
let mut letters: Vec<(char, usize)> = tally.by_letter.iter().map(|(c, n)| (*c, *n)).collect();
letters.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
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 || (n == e.1 && truth < e.0) {
*e = (truth, n);
}
}
let mut substitutions: Vec<(char, char)> = best.into_iter().map(|(lie, (truth, _))| (lie, truth)).collect();
substitutions.sort_unstable();
let pairs: Vec<(char, char, usize)> = substitutions
.iter()
.map(|&(lie, truth)| (lie, truth, lies.get(&(truth, lie)).copied().unwrap_or(0)))
.collect();
if let Some(reason) = crate::glyphmatch::subsetting_window(&pairs) {
return Ok(OutlineScan {
findings: vec![Finding::new(
"DOCX.FONT_SUBSET_WINDOW_ARTIFACT",
Severity::Info,
Category::Structural,
"embedded fonts",
reason,
0.8,
)],
substitutions: Vec::new(),
});
}
let mut findings: Vec<Finding> = lies
.into_iter()
.map(|((truth, lie), n)| {
Finding::new(
"DOCX.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's cmap lies (semantic replacement / variant A3)"
),
0.9,
)
})
.collect();
findings.sort_by(|a, b| a.message.cmp(&b.message));
Ok(OutlineScan { findings, substitutions })
}
pub fn docx_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
use std::collections::HashSet;
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
let mut out: Vec<crate::FontClaims> = Vec::new();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
let Ok(face) = FontRef::new(&de) else { continue };
let mut claims: HashSet<(char, u16)> = HashSet::new();
let charmap = face.charmap();
if charmap.is_symbol() {
continue;
}
for (cp, gid) in charmap.mappings() {
let Some(l) = letter(cp) else { continue };
if !doc_letters.contains(&l) {
continue;
}
if let Some(c) = char::from_u32(cp) {
claims.insert((c, gid.to_u32() as u16));
}
}
if !claims.is_empty() {
out.push((de, claims.into_iter().collect()));
}
break 'keys;
}
}
}
Ok(out)
}
fn tally_face(face: &FontRef, doc_letters: &HashSet<char>, sigs: &mut HashMap<u64, Tally>) {
let charmap = face.charmap();
if charmap.is_symbol() {
return;
}
for (cp, gid) in charmap.mappings() {
let Some(letter) = letter(cp) else { continue };
if !doc_letters.contains(&letter) {
continue;
}
if let Some(h) = glyph_outline_hash(face, gid.to_u32() as u16) {
*sigs.entry(h).or_default().by_letter.entry(letter).or_default() += 1;
}
}
}