Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This release has an [MSRV] of 1.88.

- File system font scanning (used for system font enumeration on macOS and Android) now parses font files in parallel and reads only the metadata it needs instead of memory-mapping whole files. ([#3][] by [@nicoburns][])
- System font scans on macOS are now cached in `~/Library/Caches/fontique`, keyed by file modification time and size, so unchanged font files aren't re-parsed on subsequent runs. ([#4][] by [@nicoburns][])
- Directory scanning on macOS now uses the `getattrlistbulk` syscall to list directories and retrieve file metadata in bulk. ([#6][] by [@nicoburns][])

### Fixed

Expand Down Expand Up @@ -601,6 +602,7 @@ This release has an [MSRV][] of 1.70.
[#213]: https://github.com/linebender/parley/pull/213
[#3]: https://github.com/DioxusLabs/parley/pull/3
[#4]: https://github.com/DioxusLabs/parley/pull/4
[#6]: https://github.com/DioxusLabs/parley/pull/6
[#215]: https://github.com/linebender/parley/pull/215
[#223]: https://github.com/linebender/parley/pull/223
[#224]: https://github.com/linebender/parley/pull/224
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion fontique/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ workspace = true

[features]
default = ["system"]
std = ["read-fonts/std", "dep:memmap2", "parlance/std"]
std = ["read-fonts/std", "dep:memmap2", "parlance/std", "dep:getattrlistbulk"]
libm = ["read-fonts/libm"]
bytemuck = ["parlance/bytemuck"]
# Enables support for system font backends
Expand Down Expand Up @@ -50,6 +50,9 @@ parlance = { workspace = true }
windows = { version = "0.62.2", features = ["Win32_Graphics_DirectWrite"], optional = true }
windows-core = { version = "0.62.2", optional = true }

[target.'cfg(target_os = "macos")'.dependencies]
getattrlistbulk = { version = "0.1.0", optional = true }

[target.'cfg(target_vendor = "apple")'.dependencies]
# FIX: Enable relax-sign-encoding to prevent the bug described in this issue: https://github.com/madsmtm/objc2/issues/566
objc2 = { version = "0.6.4", optional = true, features = ["std", "relax-sign-encoding"] }
Expand Down
47 changes: 13 additions & 34 deletions fontique/src/backend/coretext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use objc2_foundation::{
NSSearchPathDirectory, NSSearchPathDomainMask, NSSearchPathForDirectoriesInDomains,
};
use parlance::Script;
use std::path::{Path, PathBuf};
use std::path::PathBuf;

const DEFAULT_GENERIC_FAMILIES: &[(GenericFamily, &[&str])] = &[
(GenericFamily::Serif, &["Times", "Times New Roman"]),
Expand Down Expand Up @@ -121,25 +121,24 @@ fn scan_system_fonts() -> Option<scan::ScannedCollection> {
continue;
};

let path = PathBuf::from(path_cf.to_string());
if path.exists() {
paths.insert(path);
}
// Missing files are skipped by the scan itself, so no existence
// check is needed here.
paths.insert(PathBuf::from(path_cf.to_string()));
}

// Apple hides certain fonts from CTFontCollection (notably SFNS.ttf, the San Francisco
// system UI font). Scanning Library/Fonts directories catches what CoreText omits.
paths.extend(library_font_files());
paths.extend(library_font_dirs());

if paths.is_empty() {
return None;
}

Some(match scan_cache_path() {
Some(cache_path) => {
scan::ScannedCollection::from_paths_cached(paths.iter(), 0, &cache_path)
scan::ScannedCollection::from_paths_cached(paths.iter(), 8, &cache_path)
}
None => scan::ScannedCollection::from_paths(paths.iter(), 0),
None => scan::ScannedCollection::from_paths(paths.iter(), 8),
})
}

Expand All @@ -151,35 +150,15 @@ fn scan_cache_path() -> Option<PathBuf> {
Some(path)
}

fn library_font_files() -> Vec<PathBuf> {
let mut files = Vec::new();
for dir in NSSearchPathForDirectoriesInDomains(
fn library_font_dirs() -> Vec<PathBuf> {
NSSearchPathForDirectoriesInDomains(
NSSearchPathDirectory::LibraryDirectory,
NSSearchPathDomainMask::AllDomainsMask,
true,
) {
let font_dir = PathBuf::from(format!("{dir}/Fonts"));
if font_dir.is_dir() {
collect_files(&font_dir, 8, 0, &mut files);
}
}
files
}

fn collect_files(dir: &Path, max_depth: u32, depth: u32, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
if depth < max_depth {
collect_files(&path, max_depth, depth + 1, out);
}
} else {
out.push(path);
}
}
)
.iter()
.map(|dir| PathBuf::from(format!("{dir}/Fonts")))
.collect()
}

fn create_base_font(prefer_ui_font: bool) -> CFRetained<CTFont> {
Expand Down
127 changes: 100 additions & 27 deletions fontique/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ fn scan_collection(
let files = collect_font_files(paths, max_depth);
let records = match cache_path {
Some(cache_path) => parse_files_cached(&files, cache_path),
None => parse_files(&files),
None => {
let paths: Vec<PathBuf> = files.into_iter().map(|(path, _)| path).collect();
parse_files(&paths)
}
};
let mut collection = ScannedCollection::default();
let mut families: HashMap<FamilyId, (FamilyName, SmallVec<[FontInfo; 4]>)> = HashMap::default();
Expand Down Expand Up @@ -147,36 +150,101 @@ pub(crate) struct FontRecord {
pub(crate) font: FontInfo,
}

/// Expands the given paths into a deduplicated list of files, walking
/// directories up to `max_depth`.
/// Expands the given paths into a deduplicated list of files (with their
/// modification stamps, where cheaply available), walking directories up
/// to `max_depth`.
#[cfg(feature = "std")]
fn collect_font_files(
paths: impl IntoIterator<Item = impl AsRef<Path>>,
max_depth: u32,
) -> Vec<PathBuf> {
fn collect(path: &Path, max_depth: u32, depth: u32, seen: &mut HashSet<PathBuf>) {
let Ok(metadata) = path.metadata() else {
) -> Vec<(PathBuf, Option<scan_cache::FileStamp>)> {
let mut seen = HashMap::default();
for path in paths {
collect_path(path.as_ref(), max_depth, 0, &mut seen);
}
seen.into_iter().collect()
}

#[cfg(feature = "std")]
fn collect_path(
path: &Path,
max_depth: u32,
depth: u32,
seen: &mut HashMap<PathBuf, Option<scan_cache::FileStamp>>,
) {
let Ok(metadata) = path.metadata() else {
return;
};
if metadata.is_dir() {
if depth > max_depth {
return;
};
if metadata.is_dir() {
if depth > max_depth {
return;
}
walk_dir(path, max_depth, depth, seen);
} else {
seen.entry(path.to_path_buf())
.or_insert_with(|| scan_cache::FileStamp::from_metadata(&metadata));
}
}

/// Collects the files in the directory at `path` (which is at `depth`),
/// recursing into subdirectories up to `max_depth`.
///
/// On macOS, `getattrlistbulk` retrieves each entry's name, type, and
/// modification stamp in a single batched syscall, avoiding a separate
/// `stat` for every file.
#[cfg(all(feature = "std", target_os = "macos"))]
fn walk_dir(
path: &Path,
max_depth: u32,
depth: u32,
seen: &mut HashMap<PathBuf, Option<scan_cache::FileStamp>>,
) {
use getattrlistbulk::{ObjectType, RequestedAttributes, read_dir};
let attrs = RequestedAttributes {
name: true,
object_type: true,
modified_time: true,
size: true,
..Default::default()
};
let Ok(entries) = read_dir(path, attrs) else {
return;
};
for entry in entries.filter_map(|entry| entry.ok()) {
let child = path.join(&entry.name);
match entry.object_type {
Some(ObjectType::Directory) => {
if depth < max_depth {
walk_dir(&child, max_depth, depth + 1, seen);
}
}
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.filter_map(|entry| entry.ok()) {
collect(entry.path().as_path(), max_depth, depth + 1, seen);
Some(ObjectType::Regular) => {
let stamp = entry
.modified_time
.zip(entry.size)
.and_then(|(modified, size)| scan_cache::FileStamp::new(modified, size));
seen.entry(child).or_insert(stamp);
}
} else {
seen.insert(path.to_path_buf());
// Resolve symlinks (and anything unexpected) through the
// generic path, which follows them via `metadata`.
_ => collect_path(&child, max_depth, depth + 1, seen),
}
}
let mut seen = HashSet::default();
for path in paths {
collect(path.as_ref(), max_depth, 0, &mut seen);
}

#[cfg(all(feature = "std", not(target_os = "macos")))]
fn walk_dir(
path: &Path,
max_depth: u32,
depth: u32,
seen: &mut HashMap<PathBuf, Option<scan_cache::FileStamp>>,
) {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.filter_map(|entry| entry.ok()) {
collect_path(entry.path().as_path(), max_depth, depth + 1, seen);
}
seen.into_iter().collect()
}

/// Reads and parses the given font files, distributing the work across
Expand All @@ -192,19 +260,24 @@ fn parse_files(files: &[PathBuf]) -> Vec<FontRecord> {
/// As [`parse_files`], but reusing results from and refreshing the cache
/// stored at `cache_path`.
#[cfg(feature = "std")]
fn parse_files_cached(files: &[PathBuf], cache_path: &Path) -> Vec<FontRecord> {
fn parse_files_cached(
files: &[(PathBuf, Option<scan_cache::FileStamp>)],
cache_path: &Path,
) -> Vec<FontRecord> {
let mut cache = scan_cache::load(cache_path).unwrap_or_default();
let cached_file_count = cache.len();

// Partition into cache hits and files that need parsing.
let mut results: Vec<(&Path, Option<scan_cache::FileStamp>, Vec<FontRecord>)> =
Vec::with_capacity(files.len());
let mut misses: Vec<(&Path, Option<scan_cache::FileStamp>)> = Vec::new();
for path in files {
let stamp = std::fs::metadata(path)
.ok()
.as_ref()
.and_then(scan_cache::FileStamp::from_metadata);
for (path, stamp) in files {
let stamp = stamp.or_else(|| {
std::fs::metadata(path)
.ok()
.as_ref()
.and_then(scan_cache::FileStamp::from_metadata)
});
match cache.remove(path.as_path()) {
Some(cached) if stamp == Some(cached.stamp) => {
results.push((path, stamp, cached.records));
Expand Down
14 changes: 7 additions & 7 deletions fontique/src/scan_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,18 @@ pub(crate) struct FileStamp {
}

impl FileStamp {
pub(crate) fn from_metadata(metadata: &std::fs::Metadata) -> Option<Self> {
let mtime = metadata
.modified()
.ok()?
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?;
pub(crate) fn new(modified: SystemTime, size: u64) -> Option<Self> {
let mtime = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?;
Some(Self {
mtime_secs: mtime.as_secs(),
mtime_nanos: mtime.subsec_nanos(),
size: metadata.len(),
size,
})
}

pub(crate) fn from_metadata(metadata: &std::fs::Metadata) -> Option<Self> {
Self::new(metadata.modified().ok()?, metadata.len())
}
}

/// Cached scan results for a single file.
Expand Down