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] 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 40a16fe7..2261e23c 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 84a80858..d1b983af 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 32552837..d1f115da 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 72d5eafd..06b260b2 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 1e1e1967..68e37823 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.