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.
This commit is contained in:
bflatastic 2026-10-12 09:06:43 +07:00 committed by BFlat
parent d988a30607
commit f2802cca02
9 changed files with 237 additions and 52 deletions

View File

@ -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 <out> directory, or one file when <out> has an image extension or --node
@ -72,6 +76,13 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = 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<ExitCode> {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = 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}`"))),
}

View File

@ -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<ExitCode> {
// 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<ExitCode> {
// 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…");

View File

@ -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<String> {
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");
}

View File

@ -23,6 +23,8 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
}
fn session() -> (Session, DesignSystem, Vec<NodeId>) {
// 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)
}

View File

@ -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<RwLock<FontDb>> = 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<FontDb> {
if ON_DEMAND.load(Ordering::Relaxed) {
scan_system_fonts();
}
raw_db()
}
fn raw_db() -> &'static RwLock<FontDb> {
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<String, (Str
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
// The listing's own metadata (free on Windows); a symlink's target needs a lookup.
let meta = match entry.file_type() {
Ok(t) if !t.is_symlink() => 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<String, (Str
continue;
}
// Skip very large files (colour emoji); they are not outline fonts we can draw anyway.
if std::fs::metadata(&path).map(|m| m.len() > 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<Vec<u8>> {
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<u8>, 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<usize> = 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<Arc<Face>> {
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
@ -720,36 +777,46 @@ pub fn family_styles(family: &str) -> Vec<String> {
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<String, (String, Vec<Pending>)> = 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<String, (String, Vec<Pending>)> = 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

View File

@ -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());
}
}

View File

@ -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;

View File

@ -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).

View File

@ -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