mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
Headless runs (`figcraft-cli export`, `render`, `convert`, `run`, and the headless MCP server) never indexed the installed fonts, so a text layer in an installed family (Segoe UI, Inter installed per user, ...) was measured and drawn with a fallback face, while the desktop app used the real one. Auto layout sizes from MCP agents therefore differed from what users see in the app. - `figcraft_text::scan_system_fonts_on_demand()`: the font database indexes the system fonts the first time it is used (first text layout, font lookup or font listing). `figcraft-cli` turns it on at startup for every subcommand, so a run that touches no text never pays for the scan. The desktop app keeps its background scan. - `scan_system_fonts()` now runs once per process (a `OnceLock`): racing callers wait for the one directory walk instead of each walking the folders. - Opt-out for reproducible renders: `--no-system-fonts` on any subcommand, or `FIGCRAFT_NO_SYSTEM_FONTS=1` (honoured by the desktop app too). Text then uses only the built-in fonts, as before. - The scan is cheaper: it reads each file's headers, table directories and `name` tables instead of whole files (about 460 MB on this Windows machine), and takes file metadata from the directory listing instead of a lookup per file. This also speeds up the desktop app's background scan. - Browser/wasm build unchanged (the scan is a no-op there). - Docs: `docs/cli.md` (new Fonts section) and `docs/mcp.md` (Fonts under the backends table); CLI usage text mentions the flag.
70 lines
2.7 KiB
Rust
70 lines
2.7 KiB
Rust
use super::*;
|
|
|
|
fn pending(styles: &[&str]) -> FontDb {
|
|
let mut db = FontDb::default();
|
|
let entries = styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into() }).collect();
|
|
db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries));
|
|
db
|
|
}
|
|
|
|
fn left(db: &FontDb) -> Vec<String> {
|
|
db.pending.get("cjk ui").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn find_one_reads_only_the_nearest_style() {
|
|
// The files don't exist: what matters is which entry is tried, and that the rest stay pending.
|
|
let mut db = pending(&["Bold", "Light", "Regular", "Italic"]);
|
|
assert!(db.find_one("CJK UI", "Regular").is_none());
|
|
assert_eq!(left(&db), ["Bold", "Light", "Italic"]);
|
|
assert!(db.has_family("cjk ui"), "the family is still offered");
|
|
// No exact name: the nearest weight, upright before italic.
|
|
let mut db = pending(&["W6", "Italic", "W3", "Bold"]);
|
|
db.find_one("CJK UI", "Regular");
|
|
assert_eq!(left(&db), ["W6", "Italic", "Bold"]);
|
|
// `find` reads the whole family.
|
|
db.find("CJK UI", "Regular");
|
|
assert!(left(&db).is_empty() && !db.has_family("cjk ui"));
|
|
assert_eq!(pending_weight("W3"), 300);
|
|
assert_eq!(pending_weight("SemiBold"), 600);
|
|
assert_eq!(pending_weight("W12"), 400);
|
|
}
|
|
|
|
#[test]
|
|
fn system_fonts_are_scanned_once() {
|
|
// Racing callers (the desktop's background scan, on-demand lookups, tests) share one walk.
|
|
let threads: Vec<_> = (0..4).map(|_| std::thread::spawn(scan_system_fonts)).collect();
|
|
for t in threads {
|
|
t.join().unwrap();
|
|
}
|
|
scan_system_fonts_on_demand();
|
|
let _ = db();
|
|
scan_system_fonts();
|
|
let walked = !system_fonts_opted_out();
|
|
assert_eq!(SCANS.load(Ordering::Relaxed), usize::from(walked));
|
|
assert_eq!(system_fonts_scanned(), walked);
|
|
}
|
|
|
|
#[test]
|
|
fn the_scan_reads_names_like_a_whole_file_read() {
|
|
// Every installed font file (none on CI): the partial read finds the same faces and names.
|
|
let mut files = Vec::new();
|
|
for dir in system_font_dirs() {
|
|
let mut stack = vec![dir];
|
|
while let Some(d) = stack.pop() {
|
|
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
stack.push(p);
|
|
} else if p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or(""))) {
|
|
files.push(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for p in files {
|
|
let Ok(whole) = std::fs::read(&p) else { continue };
|
|
assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display());
|
|
}
|
|
}
|