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:
parent
d988a30607
commit
f2802cca02
|
|
@ -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}`"))),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue