From f2802cca024d3bc88d130ae225fb13ada864d429 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 09:06:43 +0700 Subject: [PATCH] M7: headless renders use system fonts 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. --- apps/figcraft-cli/src/main.rs | 13 +- apps/figcraft-cli/src/perf.rs | 8 +- apps/figcraft-cli/tests/fonts.rs | 47 ++++++ crates/engine/src/components_scale_tests.rs | 2 + crates/text/src/fontdb.rs | 161 ++++++++++++++------ crates/text/src/fontdb_tests.rs | 38 +++++ crates/text/src/lib.rs | 2 +- docs/cli.md | 9 ++ docs/mcp.md | 9 ++ 9 files changed, 237 insertions(+), 52 deletions(-) create mode 100644 apps/figcraft-cli/tests/fonts.rs diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 6b641f7..590b50f 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -28,6 +28,10 @@ USAGE: figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version +Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the +fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are +indexed the first time text is laid out, as in the app. + Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file per top-level frame into the directory, or one file when has an image extension or --node @@ -72,6 +76,13 @@ impl From for CliError { pub type CliResult = Result; fn run(argv: &[String]) -> CliResult { + let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); + let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); + if system_fonts { + // Text lays out with the installed fonts, like the desktop app, so headless renders and + // auto layout sizes match what users see. + figcraft_text::scan_system_fonts_on_demand(); + } let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; match cmd.as_str() { "help" | "--help" | "-h" => { @@ -142,7 +153,7 @@ fn run(argv: &[String]) -> CliResult { let a = Args::parse(rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances) + perf::run(&sizes, &instances, system_fonts) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 0d5d1c8..c08ddc0 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -292,9 +292,11 @@ fn measure_boolean_drag(table: &mut Table, col: usize) { /// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design /// system, for each instance count in `instances`; print the tables. -pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult { - // Text renders with system fonts too, like the desktop app. - figcraft_text::scan_system_fonts(); +pub fn run(sizes: &[usize], instances: &[usize], system_fonts: bool) -> CliResult { + // Text renders with system fonts too, like the desktop app; scanned up front, outside the timings. + if system_fonts { + figcraft_text::scan_system_fonts(); + } let mut table = Table { rows: Vec::new() }; for (col, n) in sizes.iter().enumerate() { eprintln!("measuring {n} layers…"); diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs new file mode 100644 index 0000000..c5901ac --- /dev/null +++ b/apps/figcraft-cli/tests/fonts.rs @@ -0,0 +1,47 @@ +//! Headless runs lay text out with the installed fonts, like the desktop app, unless +//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::process::Command; + +use serde_json::{Value, json}; + +/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment +/// variable. +fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value { + let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join(format!("{name}.json")); + std::fs::write(&script, json!([step]).to_string()).unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + cmd.arg("run").arg(&script).args(args); + if opt_out { + cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1"); + } else { + cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS"); + } + let out = cmd.output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let line: Value = serde_json::from_slice(&out.stdout).unwrap(); + line["result"].clone() +} + +fn families(args: &[&str], opt_out: bool) -> Vec { + serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap() +} + +#[test] +fn headless_runs_resolve_installed_families_unless_opted_out() { + let built_in = families(&["--no-system-fonts"], false); + assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag"); + // Any family this machine adds (scanned here, in the test); none (CI): nothing to check. + figcraft_text::scan_system_fonts(); + let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else { + eprintln!("skipped: no installed fonts beyond the built-in ones"); + return; + }; + assert!(families(&[], false).contains(&installed), "headless runs list {installed}"); + let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}}); + assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); + assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); +} diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index 3253ea8..c796dad 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -23,6 +23,8 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { } fn session() -> (Session, DesignSystem, Vec) { + // Other tests scan the system fonts; text measured before and after that scan would differ. + figcraft_text::scan_system_fonts(); let (doc, ds, inst) = design_system_with_instances(&DsSpec::SMALL, COUNT).unwrap(); (Session::with_document(doc), ds, inst) } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c40f1f..2c584eb 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1,7 +1,7 @@ //! The font database: embedded craft-fonts faces plus (on desktop) the system's fonts. use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use figcraft_geom::BezPath; @@ -410,9 +410,24 @@ pub struct FontDb { } static DB: OnceLock> = OnceLock::new(); +/// Set by [`scan_system_fonts_on_demand`]. +static ON_DEMAND: AtomicBool = AtomicBool::new(false); +/// Done once per process: concurrent callers wait for the one scan. +static SCANNED: OnceLock<()> = OnceLock::new(); +/// Directory walks done (at most one). +#[cfg(not(target_arch = "wasm32"))] +static SCANS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); -/// The process-wide font database (embedded fonts loaded on first use). +/// The process-wide font database (embedded fonts loaded on first use; system fonts indexed first +/// when [`scan_system_fonts_on_demand`] was called). pub fn db() -> &'static RwLock { + if ON_DEMAND.load(Ordering::Relaxed) { + scan_system_fonts(); + } + raw_db() +} + +fn raw_db() -> &'static RwLock { DB.get_or_init(|| { let mut db = FontDb::default(); for f in CRAFT_FONTS { @@ -670,7 +685,13 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap entry.metadata(), + _ => std::fs::metadata(&path), + }; + let Ok(meta) = meta else { continue }; + if meta.is_dir() { scan_dir(&path, depth + 1, out); continue; } @@ -679,31 +700,67 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap 64 * 1024 * 1024).unwrap_or(true) { - continue; - } - let Ok(bytes) = std::fs::read(&path) else { continue }; - let count = match FileRef::new(bytes.as_slice()) { - Ok(FileRef::Collection(c)) => c.len(), - Ok(FileRef::Font(_)) => 1, - Err(_) => 0, - }; - for i in 0..count.min(64) { - let Ok(f) = skrifa::FontRef::from_index(bytes.as_slice(), i) else { continue }; - // Only outline fonts we can draw. - if f.glyf().is_err() && f.cff().is_err() && f.cff2().is_err() { - continue; - } - let (Some(family), style) = names(&f) else { continue }; - if family.starts_with('.') { - continue; - } - let style = style.unwrap_or_else(|| "Regular".into()); - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index: i, style }); + let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 64 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue }; + for (index, family, style) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style }); } } } +/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their +/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). +fn index_bytes(path: &std::path::Path, len: usize) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = vec![0u8; len]; + let mut fill = |buf: &mut Vec, at: usize, n: usize| -> Option<()> { + let dst = buf.get_mut(at..at.checked_add(n)?)?; + file.seek(SeekFrom::Start(u64::try_from(at).ok()?)).ok()?; + file.read_exact(dst).ok() + }; + let be = |b: &[u8], at: usize, n: usize| b.get(at..at.checked_add(n)?).map(|s| s.iter().fold(0usize, |v, x| v << 8 | usize::from(*x))); + fill(&mut buf, 0, 12)?; + let fonts: Vec = if buf.get(..4) == Some(b"ttcf") { + let n = be(&buf, 8, 4)?.min(64); + fill(&mut buf, 12, 4 * n)?; + (0..n).filter_map(|i| be(&buf, 12 + 4 * i, 4)).collect() + } else { + vec![0] + }; + for at in fonts { + let Some(tables) = fill(&mut buf, at, 12).and_then(|()| be(&buf, at + 4, 2)) else { continue }; + if fill(&mut buf, at + 12, 16 * tables).is_none() { + continue; + } + for rec in (0..tables).map(|t| at + 12 + 16 * t) { + if buf.get(rec..rec + 4) == Some(b"name") + && let (Some(off), Some(n)) = (be(&buf, rec + 8, 4), be(&buf, rec + 12, 4)) + { + let _ = fill(&mut buf, off, n); + } + } + } + Some(buf) +} + +/// (index, family, style) of each outline face (one we can draw) in a font file. +fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { + let count = match FileRef::new(bytes) { + Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Font(_)) => 1, + Err(_) => 0, + }; + (0..count.min(64)) + .filter_map(|i| { + let f = skrifa::FontRef::from_index(bytes, i).ok()?; + [b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?; + let (family, style) = names(&f); + let family = family.filter(|f| !f.starts_with('.'))?; + Some((i, family, style.unwrap_or_else(|| "Regular".into()))) + }) + .collect() +} + /// Convenience: find a face with the global db. pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -720,36 +777,46 @@ pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) } -/// Scan system fonts into the global db (desktop; a no-op on the web). The directory walk runs -/// without holding the database lock; results are merged in one short write. +/// Scan system fonts into the global db (desktop; a no-op on the web, or with +/// `FIGCRAFT_NO_SYSTEM_FONTS` set). Runs once per process: a concurrent caller waits for that scan. +/// The directory walk runs without holding the database lock; results are merged in one short write. pub fn scan_system_fonts() { - #[cfg(not(target_arch = "wasm32"))] - { - if db().read().unwrap_or_else(std::sync::PoisonError::into_inner).system_scanned { - return; - } - let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } - let mut w = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); - if w.system_scanned { - return; - } - w.system_scanned = true; - for (k, v) in found { - if !w.families.contains_key(&k) { - w.pending.entry(k).or_insert(v); + SCANNED.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + SCANS.fetch_add(1, Ordering::Relaxed); + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); } + let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + w.system_scanned = true; + for (k, v) in found { + if !w.families.contains_key(&k) { + w.pending.entry(k).or_insert(v); + } + } + drop(w); + crate::clear_layout_cache(); } - drop(w); - crate::clear_layout_cache(); - } + }); +} + +/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. +#[cfg(not(target_arch = "wasm32"))] +fn system_fonts_opted_out() -> bool { + std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Index the system fonts the first time the font database is used (headless apps: a run that +/// lays out no text never pays for the scan). The desktop app scans in the background instead. +pub fn scan_system_fonts_on_demand() { + ON_DEMAND.store(true, Ordering::Relaxed); } /// Whether the system fonts were indexed ([`scan_system_fonts`] finished; always false on the web). pub fn system_fonts_scanned() -> bool { - db().read().unwrap_or_else(std::sync::PoisonError::into_inner).system_scanned + raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).system_scanned } /// The first of `families` (embedded or system) whose regular face draws `c`, for a UI toolkit diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 293ee81..b9add9c 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -29,3 +29,41 @@ fn find_one_reads_only_the_nearest_style() { 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()); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b233f06..17251f0 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -15,7 +15,7 @@ mod onpath; pub use breaks::grapheme_step; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, system_fonts_scanned, + scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/docs/cli.md b/docs/cli.md index 3f5d6e5..372857f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,15 @@ commands, serve MCP and measure performance. Build it with Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +## Fonts + +Text lays out and renders with the same fonts as the desktop app: the fonts built into FigCraft +plus the installed system fonts. The system fonts are indexed once per run, the first time text is +laid out (about 60 ms warm on a Windows machine with ~900 font files; a run without text skips it). +Every subcommand takes `--no-system-fonts` (or set `FIGCRAFT_NO_SYSTEM_FONTS=1`, which the desktop +app honours too) to use only the built-in fonts, for renders that come out the same on every +machine, e.g. in CI. + The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p figcraft-server`): it keeps files with share links and serves their live sessions, and its `key`, `files` and `links` subcommands manage them. See [server.md](server.md). diff --git a/docs/mcp.md b/docs/mcp.md index 9bb320c..3c58811 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -14,6 +14,15 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI: `--attach` also takes `host:port`. The control channel only listens on 127.0.0.1. +### Fonts + +The headless server lays text out with the same fonts as the app: the fonts built into FigCraft +plus the installed system fonts, indexed once, the first time text is laid out (a session that +never touches text never pays for it). So text sizes, auto layout and exports match what users see +in the app. For renders that come out the same on every machine (CI), start it with +`figcraft-cli mcp --no-system-fonts` or set `FIGCRAFT_NO_SYSTEM_FONTS=1`: text then uses only the +built-in fonts. Attached, the app's own fonts apply. The browser build has no system fonts. + ## Configure a client Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to