From f1b41ffeaa00a6bb27ad2b9cf26f92d9e27f399c Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:45:20 -0400 Subject: [PATCH 1/3] M7: find fonts that macOS font managers activate outside the font folders Font managers such as RightFont, FontBase and Typeface keep fonts in their own library folders and activate them through Core Text, without copying them into /Library/Fonts or ~/Library/Fonts. Every other Mac app lists these fonts; FigCraft walks only the font folders, so it didn't, and a file using one of them drew in a fallback font. On macOS the scan now also asks Core Text for the file of every available font and indexes the files that lie outside the walked folders. All three scans (the first, the on-demand one and text.rescanFonts) share this, so a font activated while the app runs is found by a rescan like a newly installed one. Core Text needs unsafe FFI, which the workspace forbids, so the query goes through the safe wrappers of the core-text crate, pinned at =20.1.0. That version builds on the core-foundation 0.9, core-graphics 0.23 and foreign-types 0.5 the lockfile already has: core-text is the only new package. The call sits inside catch_unwind, so anything unexpected from Core Text leaves the scan with the font folders alone. Other platforms are unchanged. On a Mac with RightFont, 15 families from its library are now listed (Geist, JetBrains Mono, Clash Display and others) that weren't before. A full font listing went from 0.06 s to 0.20 s on that machine. Tests: the filter that keeps files outside the font folders once, indexing a font file found outside them, and (macOS only) that Core Text lists the system's font files. cargo test --workspace, cargo clippy -p figcraft-text --all-targets -D warnings and cargo fmt --check pass on macOS 15. CI builds on Linux only, so the macOS path is checked locally. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 5699e7abe6e5994e526051774ec92aadddb0550c) --- Cargo.lock | 13 +++++ crates/text/Cargo.toml | 8 +++ crates/text/src/fontdb.rs | 94 ++++++++++++++++++++++----------- crates/text/src/fontdb_tests.rs | 49 +++++++++++++++++ docs/cli.md | 6 +++ 5 files changed, 138 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7e..2261e23cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,18 @@ dependencies = [ "libc", ] +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1782,6 +1794,7 @@ dependencies = [ name = "figcraft-text" version = "0.11.2" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 84a80858a..d1b983af5 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -18,3 +18,11 @@ png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } + +# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font +# folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text +# needs unsafe FFI, which the workspace forbids). Its dependencies (core-foundation 0.9, +# core-graphics 0.23, foreign-types 0.5, libc) are already in the lockfile. Pinned exactly: review +# the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +core-text = { version = "=20.1.0", default-features = false } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 32552837a..d1f115da7 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -817,9 +817,7 @@ impl FontDb { } self.system_scanned = true; let mut found = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); self.merge_scan(found); } @@ -924,29 +922,45 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { - continue; - } - let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; - for (index, family, style, postscript) in faces_in(&bytes) { - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { - path: path.clone(), - index, - style, - postscript, - }); + index_font_file(&path, &meta, out); + } +} + +fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut BTreeMap)>) { + // Font-like names can also belong to pipes or devices; opening a pipe can block. + // `meta` follows symlinks, so links to regular font files remain supported. + if !meta.is_file() { + return; + } + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + return; + } + // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the + // system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed + // buffer, so only those parts are ever touched. + if meta.len() > MAX_FONT_FILE { + return; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(path, l)) else { return }; + for (index, family, style, postscript) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { + path: path.to_path_buf(), + index, + style, + postscript, + }); + } +} + +fn scan_installed_fonts(out: &mut BTreeMap)>) { + let dirs = system_font_dirs(); + for dir in &dirs { + scan_dir(dir, 0, out); + } + for path in files_outside_dirs(font_manager_files(), &dirs) { + if let Ok(meta) = std::fs::metadata(&path) { + index_font_file(&path, &meta, out); } } } @@ -959,6 +973,26 @@ fn starts_like_a_font(path: &std::path::Path) -> bool { std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") } +fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet { + files.into_iter().filter(|file| file.is_absolute() && !dirs.iter().any(|dir| file.starts_with(dir))).collect() +} + +#[cfg(target_os = "macos")] +fn font_manager_files() -> Vec { + std::panic::catch_unwind(|| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "macos"))] +fn font_manager_files() -> Vec { + Vec::new() +} + /// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; @@ -1082,9 +1116,7 @@ pub fn scan_system_fonts() { 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); - } + scan_installed_fonts(&mut found); let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); w.system_scanned = true; w.merge_scan(found); @@ -1108,9 +1140,7 @@ pub fn rescan_system_fonts() -> Rescan { return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); if !rescan.changed.is_empty() { crate::clear_layout_cache(); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 72d5eafdd..06b260b25 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -440,3 +440,52 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { assert!(at(thai).is_some_and(|i| i > latin), "{thai}"); } } + +#[test] +fn font_manager_files_outside_the_font_folders_are_indexed_once() { + let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; + let library = std::path::PathBuf::from("/Users/a/Fonts.rightfontlibrary/fonts/I/Inter/InterVariable.ttf"); + let files = vec![ + std::path::PathBuf::from("/Library/Fonts/Arial.ttf"), + std::path::PathBuf::from("/Users/a/Library/Fonts/Sub/Brand.otf"), + library.clone(), + library.clone(), + std::path::PathBuf::from("relative/Font.ttf"), + std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), + ]; + let outside: Vec<_> = files_outside_dirs(files, &folders).into_iter().collect(); + assert_eq!(outside, [std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), library]); +} + +#[test] +fn a_font_file_outside_the_font_folders_is_indexed_from_its_path() { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-manager-{}", std::process::id()))); + std::fs::create_dir_all(&tmp.0).unwrap(); + let copy = tmp.0.join("Activated.ttf"); + std::fs::copy(&font, ©).unwrap(); + let mut pending = BTreeMap::new(); + index_font_file(©, &std::fs::metadata(©).unwrap(), &mut pending); + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == copy), "{paths:?}"); + let mut none = BTreeMap::new(); + index_font_file(&tmp.0, &std::fs::metadata(&tmp.0).unwrap(), &mut none); + assert!(none.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn core_text_lists_the_installed_font_files() { + let files = font_manager_files(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_absolute())); +} diff --git a/docs/cli.md b/docs/cli.md index 1e1e1967b..68e37823c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,12 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the +system's downloadable fonts) are read, and so is every other font file Core Text lists as +available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own +library, without copying them into a font folder. Fonts activated while the app runs are found by +`text.rescanFonts`, like newly installed ones. + On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them (`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while they're active there. Files without an extension are read when they start like a font file. From 5e32057bdde3b071ea736cfcb9de22efc559f0c0 Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:53:16 -0400 Subject: [PATCH 2/3] M7: font manager scan: drain Core Text's autoreleased objects; core-text 22 Review on #1309 pointed out that core-text 20.1's get_descriptors retains the array it gets under the create rule a second time, so every scan kept one array of font descriptors alive. Checking that turned up a larger leak: Core Text autoreleases objects while it builds the collection, and the scan runs where no autorelease pool is active (the app's background scan thread, the CLI, the MCP server), so nothing ever drained them. Measured with `figcraft-cli run` of N `text.rescanFonts` steps on macOS 15 (peak memory footprint): | | 10 rescans | 200 rescans | |---|---|---| | main, without this PR | 221 MB | 220 MB | | this PR before the fix | 228 MB | 796 MB | | with the fix | 226 MB | 225 MB | A standalone Swift loop over the same Core Text calls grows from 9 MB to 759 MB over 200 scans without a pool and stays at 20 MB inside one, so the pool is what matters; the core-text bump removes the over-retain on top. - The query runs inside objc2::rc::autoreleasepool. objc2 0.6 is already in the lockfile (winit's), and the function is safe, so the workspace's unsafe_code = "forbid" still holds. - core-text goes to =22.0.0, the first release that takes ownership of the array correctly. It brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older versions other crates use. 22.0 moved framework linking behind a `link` feature that is on by default, so with default features off it is named explicitly. A rescan takes about 0.2 s instead of 0.08 s on that Mac (266 more font files are indexed); it only runs when asked for. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 7f6a3a4e46922d737b8053c7cd5968a02a558ae3) --- Cargo.lock | 40 +++++++++++++++++++++++++++++++-------- crates/text/Cargo.toml | 10 ++++++---- crates/text/src/fontdb.rs | 10 ++++++---- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2261e23cd..24536ff97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -872,17 +885,27 @@ dependencies = [ ] [[package]] -name = "core-text" -version = "20.1.0" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1799,6 +1822,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -5995,7 +6019,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index d1b983af5..5322ca588 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -21,8 +21,10 @@ log = { workspace = true } # Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font # folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text -# needs unsafe FFI, which the workspace forbids). Its dependencies (core-foundation 0.9, -# core-graphics 0.23, foreign-types 0.5, libc) are already in the lockfile. Pinned exactly: review -# the diff before bumping. +# needs unsafe FFI, which the workspace forbids). 22.0 is the first release whose +# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); +# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older +# versions other crates use. Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "macos")'.dependencies] -core-text = { version = "=20.1.0", default-features = false } +core-text = { version = "=22.0.0", default-features = false, features = ["link"] } +objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d1f115da7..ced18c815 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -980,10 +980,12 @@ fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf #[cfg(target_os = "macos")] fn font_manager_files() -> Vec { std::panic::catch_unwind(|| { - core_text::font_collection::create_for_all_families() - .get_descriptors() - .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) - .unwrap_or_default() + objc2::rc::autoreleasepool(|_| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) }) .unwrap_or_default() } From f89f656d16a948451888efb229fa0cbdfa043eab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 22:19:46 +0700 Subject: [PATCH 3/3] #1309: the font-manager path test runs on Unix only (its paths aren't absolute on Windows) --- crates/text/src/fontdb_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 06b260b25..85d1f3a78 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -441,6 +441,8 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { } } +// The paths are Unix absolute paths (font managers are a macOS thing): on Windows they aren't absolute. +#[cfg(unix)] #[test] fn font_manager_files_outside_the_font_folders_are_indexed_once() { let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")];