font-catalog: hand fontique only bounded font-store files (#537)

* font-catalog: hand fontique only bounded font-store files

fontique 0.11 imports a collection by registering the whole file once
for each face in it, scanning every face again each time and rebuilding
the family's font list, so a downloaded collection whose header declares
many faces costs far more than its size. The store is now walked here
(bounded depth and file count, no symbolic links) and collections that
declare more than 64 faces are left to the catalog's own scan, which
reads each face once.

* font-catalog: load font-store files one at a time and keep fontique's walk

fontique 0.11 re-adds every font gathered in one load_fonts_from_paths
call to its family for each face it registers, so handing it the whole
store at once repeats the store's fonts quadratically in the file count
(126,230 family entries for 2,084 faces on a 400-file test store, 4 s;
one call per file gives the same families, fonts, order and default
fonts with 2,084 entries in 90 ms).

The store walk now matches what fontique did with the folder before:
links are followed (to regular files and folders, each folder once),
depth 16, read_dir order, with a 200,000-entry budget like the
catalog's scan. Pipes, devices and dangling links are skipped.
This commit is contained in:
Dylan M. Taylor
2026-11-11 06:08:19 -05:00
committed by GitHub
parent 0230aef364
commit acc262e69c
+151 -2
View File
@@ -9,7 +9,8 @@
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
mod imp {
use std::path::PathBuf;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use fontique::{Collection, CollectionOptions, SourceKind};
@@ -29,13 +30,88 @@ mod imp {
std::panic::catch_unwind(|| {
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: true });
if let Some(store) = font_store_dir(&FontEnv::from_process()).filter(|d| d.is_dir()) {
collection.load_fonts_from_paths([store]);
load_store(&mut collection, &store);
}
collection
})
.ok()
}
/// Adds the font store `dir`'s fonts ([`store_files`]) to `collection`, one file at a time:
/// fontique 0.11 adds every font gathered in one `load_fonts_from_paths` call to its family
/// again for each face it registers, so one call over the whole store repeats its fonts
/// quadratically in the number of files.
fn load_store(collection: &mut Collection, dir: &Path) {
for file in store_files(dir) {
collection.load_fonts_from_paths([file]);
}
}
/// Most faces a font-store collection may have to be handed to fontique. fontique 0.11
/// registers a collection file once for every face in it, each time scanning all its faces
/// again and rebuilding the family's font list, so the work grows with the fourth power of
/// the face count. Larger collections are left to the catalog's own scan, which reads each
/// face once.
const MAX_COLLECTION_FACES: u32 = 64;
/// Most font-store files handed to fontique.
const MAX_STORE_FILES: usize = 20_000;
/// Most font-store folder entries looked at, as the catalog's scan bounds its own walk.
const MAX_STORE_ENTRIES: usize = 200_000;
/// How deep the store's folders are walked: fontique's own depth for a folder it is given.
const MAX_STORE_DEPTH: u32 = 16;
/// The regular files under the font store `dir` that fontique may import, in the order
/// fontique walks a folder: at most [`MAX_STORE_FILES`] of the first [`MAX_STORE_ENTRIES`]
/// entries, no deeper than [`MAX_STORE_DEPTH`], without collections of more than
/// [`MAX_COLLECTION_FACES`] faces and without pipes, devices or other special files. Links
/// are followed, as fontique and the catalog's scan follow them; a folder reached again
/// through a link is walked once.
fn store_files(dir: &Path) -> Vec<PathBuf> {
let mut walk = StoreWalk { files: Vec::new(), entries: 0, seen_dirs: HashSet::new() };
walk.dir(dir, 0);
walk.files
}
struct StoreWalk {
files: Vec<PathBuf>,
entries: usize,
seen_dirs: HashSet<PathBuf>,
}
impl StoreWalk {
fn dir(&mut self, dir: &Path, depth: u32) {
if !self.seen_dirs.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())) {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
self.entries += 1;
if self.entries > MAX_STORE_ENTRIES || self.files.len() >= MAX_STORE_FILES {
return;
}
let path = entry.path();
let Ok(meta) = std::fs::metadata(&path) else { continue };
if meta.is_dir() {
if depth < MAX_STORE_DEPTH {
self.dir(&path, depth + 1);
}
} else if meta.is_file() && collection_faces(&path) <= MAX_COLLECTION_FACES {
self.files.push(path);
}
}
}
}
/// The face count a collection's header declares; 1 for any other file.
fn collection_faces(path: &Path) -> u32 {
use std::io::Read;
let mut head = [0u8; 12];
match std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut head)) {
Ok(()) if head.starts_with(b"ttcf") => u32::from_be_bytes([head[8], head[9], head[10], head[11]]),
_ => 1,
}
}
fn with<R>(refresh: bool, f: impl FnOnce(&mut Shared) -> R) -> R {
let mut guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
let generation = guard.as_ref().map_or(0, |s| s.generation);
@@ -83,6 +159,79 @@ mod imp {
files
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_files_leave_out_large_collections_and_deep_folders_and_follow_links() {
let dir = std::env::temp_dir().join(format!("craft-font-catalog-store-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let family = dir.join("source/family");
std::fs::create_dir_all(&family).unwrap();
let deepest = (0..MAX_STORE_DEPTH).fold(dir.clone(), |d, i| d.join(i.to_string()));
std::fs::create_dir_all(deepest.join("too-deep")).unwrap();
let ttc = |faces: u32| [b"ttcf".as_slice(), &0x0001_0000u32.to_be_bytes(), &faces.to_be_bytes()].concat();
std::fs::write(family.join("Face.ttf"), b"\0\x01\0\0 a font").unwrap();
std::fs::write(family.join("Small.ttc"), ttc(4)).unwrap();
std::fs::write(family.join("Limit.ttc"), ttc(MAX_COLLECTION_FACES)).unwrap();
std::fs::write(family.join("Huge.ttc"), ttc(100_000)).unwrap();
std::fs::write(family.join("tiny"), b"x").unwrap();
std::fs::write(deepest.join("Deepest.ttf"), b"font").unwrap();
std::fs::write(deepest.join("too-deep/Deep.ttf"), b"font").unwrap();
let mut expected =
vec![family.join("Face.ttf"), family.join("Small.ttc"), family.join("Limit.ttc"), family.join("tiny"), deepest.join("Deepest.ttf")];
#[cfg(unix)]
{
let other = dir.with_extension("elsewhere");
let _ = std::fs::remove_dir_all(&other);
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("Linked.ttf"), b"font").unwrap();
std::os::unix::fs::symlink(other.join("Linked.ttf"), family.join("Link.ttf")).unwrap();
std::os::unix::fs::symlink(&other, dir.join("linked-folder")).unwrap();
std::os::unix::fs::symlink(other.join("Missing.ttf"), family.join("Dangling.ttf")).unwrap();
std::os::unix::fs::symlink(&dir, family.join("loop")).unwrap();
expected.extend([family.join("Link.ttf"), dir.join("linked-folder/Linked.ttf")]);
}
let mut files = store_files(&dir);
files.sort();
expected.sort();
assert_eq!(files, expected);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(dir.with_extension("elsewhere"));
}
#[test]
fn store_fonts_join_their_family_once() {
use write_fonts::FontBuilder;
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::name::{Name, NameRecord};
use write_fonts::tables::os2::Os2;
use write_fonts::types::{GlyphId, NameId};
let font = |style: &str, weight: u16| {
let records = [(1, "Craft Store Sans".to_string()), (2, style.to_string()), (6, format!("CraftStoreSans-{style}"))]
.map(|(id, text)| NameRecord::new(3, 1, 0x409, NameId::new(id), text.into()));
let mut builder = FontBuilder::new();
builder.add_table(&Name::new(records.into())).unwrap();
builder.add_table(&Os2 { us_weight_class: weight, us_width_class: 5, ..Default::default() }).unwrap();
builder.add_table(&Cmap::from_mappings([('a', GlyphId::new(1))]).unwrap()).unwrap();
builder.build()
};
let dir = std::env::temp_dir().join(format!("craft-font-catalog-load-store-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let family = dir.join("source/store-sans");
std::fs::create_dir_all(&family).unwrap();
for (style, weight) in [("Regular", 400), ("Bold", 700), ("Light", 300)] {
std::fs::write(family.join(format!("StoreSans-{style}.ttf")), font(style, weight)).unwrap();
}
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: false });
load_store(&mut collection, &dir);
let fonts = collection.family_by_name("Craft Store Sans").unwrap().fonts().len();
assert_eq!(fonts, 3);
let _ = std::fs::remove_dir_all(&dir);
}
}
}
#[cfg(not(all(feature = "system", not(target_arch = "wasm32"))))]