Merge pull request #1309 from denisg0mes/macos-font-manager-fonts

M7: find fonts that macOS font managers activate outside the font folders
This commit is contained in:
BFlat
2026-10-26 22:33:42 +07:00
committed by GitHub
5 changed files with 170 additions and 34 deletions
Generated
+39 -2
View File
@@ -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",
]
@@ -871,6 +884,28 @@ dependencies = [
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"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"
@@ -1782,10 +1817,12 @@ dependencies = [
name = "figcraft-text"
version = "0.11.2"
dependencies = [
"core-text",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
"objc2 0.6.5",
"png",
"skrifa 0.47.0",
"unicode-bidi",
@@ -5982,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",
+10
View File
@@ -18,3 +18,13 @@ 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). 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 = "=22.0.0", default-features = false, features = ["link"] }
objc2 = "0.6"
+64 -32
View File
@@ -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<String, (Str
scan_dir(&path, depth + 1, out);
continue;
}
// 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() {
continue;
}
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)) {
continue;
}
// 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 {
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<String, (String, Vec<Pending>)>) {
// 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<String, (String, Vec<Pending>)>) {
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,28 @@ 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<std::path::PathBuf>, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet<std::path::PathBuf> {
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::path::PathBuf> {
std::panic::catch_unwind(|| {
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()
}
#[cfg(not(target_os = "macos"))]
fn font_manager_files() -> Vec<std::path::PathBuf> {
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 +1118,7 @@ pub fn scan_system_fonts() {
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);
}
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 +1142,7 @@ pub fn rescan_system_fonts() -> Rescan {
return Rescan { changed: added.clone(), added };
}
let mut found: BTreeMap<String, (String, Vec<Pending>)> = 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();
+51
View File
@@ -440,3 +440,54 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() {
assert!(at(thai).is_some_and(|i| i > latin), "{thai}");
}
}
// 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")];
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, &copy).unwrap();
let mut pending = BTreeMap::new();
index_font_file(&copy, &std::fs::metadata(&copy).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()));
}
+6
View File
@@ -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.