Files
pdfcraft/crates/engine/src/text_fonts.rs
T
FredAppsandClaude Opus 5.5 92de289ecc fix(engine): spend the font budget only on fonts the picker lists
discover charged each candidate's size against the 64 MiB budget before
reading it, so proprietary or broken files early in a fonts folder could
use the budget up and hide openly licensed fonts after them. Charge only
fonts that pass validation and are not duplicates. Reads are still
bounded: every file read counts against four times the budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:33:50 +01:00

218 lines
9.7 KiB
Rust

//! Explicit document-font discovery. Interface fallbacks never enter this catalogue.
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct FontFile {
pub path: PathBuf,
pub family: String,
pub face: String,
}
/// Standard user-installed font folders; discovery is performed only on request.
pub fn installed_directories() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
let mut dirs = vec![PathBuf::from("/Library/Fonts")];
if let Some(home) = home {
dirs.push(home.join("Library/Fonts"));
}
dirs
} else if cfg!(windows) {
let mut dirs = std::env::var_os("WINDIR").map(|p| PathBuf::from(p).join("Fonts")).into_iter().collect::<Vec<_>>();
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(local).join("Microsoft/Windows/Fonts"));
}
dirs
} else {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
if let Some(home) = home {
dirs.extend([home.join(".local/share/fonts"), home.join(".fonts")]);
}
dirs
}
}
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
/// The caller controls roots (automation resolves confinement before calling this).
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
discover_budgeted(roots, 64u64 << 20)
}
/// Bounded, symlink-free scan. `budget` caps the total size of the fonts listed: a file that
/// is rejected (unreadable, not openly licensed, a duplicate) does not spend it. Every file read
/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan.
pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec<FontFile> {
use std::io::Read;
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
let mut files = Vec::new();
let mut entries = 0usize;
let mut read_allowance = budget.saturating_mul(4);
let mut seen = std::collections::HashSet::new();
let mut seen_face_content = std::collections::HashSet::new();
while let Some((path, depth)) = pending.pop() {
if entries >= 4096 || budget == 0 || read_allowance == 0 {
break;
}
entries += 1;
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
if meta.file_type().is_symlink() || !seen.insert(path.clone()) {
continue;
}
if meta.is_dir() && depth < 4 {
if let Ok(children) = std::fs::read_dir(&path) {
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
// Best effort: unreadable entries are skipped and do not count against the entry allowance.
let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect();
sorted_children.sort();
pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1)));
}
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
if meta.len() > budget || meta.len() > read_allowance {
continue;
}
read_allowance = read_allowance.saturating_sub(meta.len());
let mut bytes = Vec::new();
let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes));
if read.is_err() || bytes.len() != meta.len() as usize {
continue;
}
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
continue;
}
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
let key = font_content_key(&bytes);
if seen_face_content.insert((face.clone(), key)) {
budget = budget.saturating_sub(meta.len());
files.push(FontFile { path, family, face });
}
}
}
}
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
files.truncate(500);
files
}
fn font_content_key(bytes: &[u8]) -> (usize, u64) {
use std::hash::{DefaultHasher, Hasher};
let mut h = DefaultHasher::new();
h.write(bytes);
(bytes.len(), h.finish())
}
fn font_extension(path: &Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_installed_directories() {
let dirs = installed_directories();
assert!(!dirs.is_empty());
}
#[test]
fn test_font_extension() {
assert!(font_extension(Path::new("Arial.ttf")));
assert!(font_extension(Path::new("Noto.otf")));
assert!(font_extension(Path::new("CUSTOM.TTF")));
assert!(font_extension(Path::new("ROBOTO.OTF")));
assert!(!font_extension(Path::new("document.pdf")));
assert!(!font_extension(Path::new("font.woff")));
assert!(!font_extension(Path::new("font.woff2")));
assert!(!font_extension(Path::new("no_extension")));
}
#[test]
fn test_font_content_key() {
let empty = font_content_key(&[]);
assert_eq!(empty.0, 0);
let a = font_content_key(b"TrueType font sample data bytes");
let b = font_content_key(b"TrueType font sample data bytes");
let c = font_content_key(b"Different font sample data bytes");
assert_eq!(a, b, "identical bytes must produce identical keys");
assert_ne!(a, c, "differing bytes must produce distinct keys");
assert_eq!(a.0, b"TrueType font sample data bytes".len());
}
#[test]
fn test_discover_empty_and_nonexistent_roots() {
let empty = discover(&[]);
assert!(empty.is_empty());
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
assert!(nonexistent.is_empty());
}
#[test]
fn test_discover_deduplicates_non_adjacent_same_content() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
let dir_a = root.join("a");
let dir_b = root.join("b");
let dir_c = root.join("c");
let _ = std::fs::create_dir_all(&dir_a);
let _ = std::fs::create_dir_all(&dir_b);
let _ = std::fs::create_dir_all(&dir_c);
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
if inter_reg.exists() && inter_med.exists() {
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
let discovered = discover(std::slice::from_ref(&root));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
assert!(faces.contains(&"Inter-Regular"));
assert!(faces.contains(&"Inter-Medium"));
} else {
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len();
let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len();
// An unusable candidate, scanned first (sorted before the real fonts).
std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap();
std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap();
// The budget fits Inter-Regular alone; the rejected file must not use it up first.
let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::<Vec<_>>();
assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"], "a rejected file spends no budget");
assert_eq!(faces(&run1), faces(&run2));
// Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular.
std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap();
let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
assert_eq!(one.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"]);
let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len);
assert_eq!(both.len(), 2);
// Reading stops at four times the budget, whatever was rejected.
for i in 0..4 {
std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap();
}
let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let _ = std::fs::remove_dir_all(&root);
assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts");
}
}