From 237a40b434f542200f86db37f7c0ec1c0bc76288 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 5 Aug 2026 00:58:12 +0000 Subject: [PATCH 1/2] fontique: add persistent cache for system font scans on macOS --- fontique/src/backend/coretext.rs | 15 +- fontique/src/charmap.rs | 18 ++ fontique/src/font.rs | 66 ++++++-- fontique/src/lib.rs | 2 + fontique/src/scan.rs | 235 ++++++++++++++++++++++++-- fontique/src/scan_cache.rs | 273 +++++++++++++++++++++++++++++++ 6 files changed, 576 insertions(+), 33 deletions(-) create mode 100644 fontique/src/scan_cache.rs diff --git a/fontique/src/backend/coretext.rs b/fontique/src/backend/coretext.rs index 5ee14db18..5f3a9d0be 100644 --- a/fontique/src/backend/coretext.rs +++ b/fontique/src/backend/coretext.rs @@ -135,7 +135,20 @@ fn scan_system_fonts() -> Option { return None; } - Some(scan::ScannedCollection::from_paths(paths.iter(), 0)) + Some(match scan_cache_path() { + Some(cache_path) => { + scan::ScannedCollection::from_paths_cached(paths.iter(), 0, &cache_path) + } + None => scan::ScannedCollection::from_paths(paths.iter(), 0), + }) +} + +/// Returns the path of the persistent font scan cache, which lets us avoid +/// re-parsing font files that haven't changed since the last scan. +fn scan_cache_path() -> Option { + let mut path = std::env::home_dir()?; + path.push("Library/Caches/fontique/font-scan-cache.bin"); + Some(path) } fn library_font_files() -> Vec { diff --git a/fontique/src/charmap.rs b/fontique/src/charmap.rs index ecf0b24f4..04af0a2b6 100644 --- a/fontique/src/charmap.rs +++ b/fontique/src/charmap.rs @@ -38,6 +38,24 @@ impl CharmapIndex { }) } + /// Decomposes the index into raw parts for serialization. + #[cfg(feature = "std")] + pub(crate) fn to_parts(self) -> (u32, bool, bool) { + (self.subtable_offset, self.is_symbol, self.is_mac_roman) + } + + /// Recreates an index from raw parts produced by [`Self::to_parts`]. + #[cfg(feature = "std")] + pub(crate) fn from_parts( + (subtable_offset, is_symbol, is_mac_roman): (u32, bool, bool), + ) -> Self { + Self { + subtable_offset, + is_symbol, + is_mac_roman, + } + } + /// Creates a character map from the given font data. pub fn charmap<'a>(&self, font_data: &'a [u8]) -> Option> { let subtable_data = font_data.get(self.subtable_offset as usize..)?; diff --git a/fontique/src/font.rs b/fontique/src/font.rs index d0424430f..ca7325320 100644 --- a/fontique/src/font.rs +++ b/fontique/src/font.rs @@ -12,7 +12,7 @@ use core::fmt; use read_fonts::{FontRef, TableProvider as _, types::Tag}; use smallvec::SmallVec; -type AxisVec = SmallVec<[AxisInfo; 1]>; +pub(crate) type AxisVec = SmallVec<[AxisInfo; 1]>; /// Representation of a single font in a family. #[derive(Clone, Debug)] @@ -213,30 +213,21 @@ impl FontInfo { // a valid cmap so just bail here if we fail. let charmap_index = CharmapIndex::new(font)?; let (width, style, weight) = read_attributes(font); - let (axes, attr_axes) = if let Ok(fvar_axes) = font.fvar().and_then(|fvar| fvar.axes()) { - let mut axes = SmallVec::<[AxisInfo; 1]>::with_capacity(fvar_axes.len()); - let mut attrs_axes = 0_u8; + let axes = if let Ok(fvar_axes) = font.fvar().and_then(|fvar| fvar.axes()) { + let mut axes = AxisVec::with_capacity(fvar_axes.len()); for fvar_axis in fvar_axes { - let axis = AxisInfo { + axes.push(AxisInfo { tag: fvar_axis.axis_tag(), min: fvar_axis.min_value().to_f32(), max: fvar_axis.max_value().to_f32(), default: fvar_axis.default_value().to_f32(), - }; - axes.push(axis); - match &axis.tag.to_be_bytes() { - b"wght" => attrs_axes |= WEIGHT_AXIS, - b"wdth" => attrs_axes |= WIDTH_AXIS, - b"slnt" => attrs_axes |= SLANT_AXIS, - b"ital" => attrs_axes |= ITALIC_AXIS, - b"opsz" => attrs_axes |= OPTICAL_SIZE_AXIS, - _ => {} - } + }); } - (axes, attrs_axes) + axes } else { - (SmallVec::default(), 0) + AxisVec::default() }; + let attr_axes = attr_axes_from_axes(&axes); Some(Self { source, index, @@ -249,6 +240,32 @@ impl FontInfo { }) } + /// Recreates a font from parts previously obtained via the public + /// accessors, for example when loading from a scan cache. + #[cfg(feature = "std")] + pub(crate) fn from_parts( + source: SourceInfo, + index: u32, + width: FontWidth, + style: FontStyle, + weight: FontWeight, + axes: impl Into, + charmap_index: CharmapIndex, + ) -> Self { + let axes = axes.into(); + let attr_axes = attr_axes_from_axes(&axes); + Self { + source, + index, + width, + style, + weight, + axes, + attr_axes, + charmap_index, + } + } + #[allow(unused)] pub(crate) fn maybe_override_attributes( &mut self, @@ -289,6 +306,21 @@ impl FontInfo { } } +fn attr_axes_from_axes(axes: &[AxisInfo]) -> u8 { + let mut attr_axes = 0_u8; + for axis in axes { + match &axis.tag.to_be_bytes() { + b"wght" => attr_axes |= WEIGHT_AXIS, + b"wdth" => attr_axes |= WIDTH_AXIS, + b"slnt" => attr_axes |= SLANT_AXIS, + b"ital" => attr_axes |= ITALIC_AXIS, + b"opsz" => attr_axes |= OPTICAL_SIZE_AXIS, + _ => {} + } + } + attr_axes +} + const WEIGHT_AXIS: u8 = 0x01; const WIDTH_AXIS: u8 = 0x02; const SLANT_AXIS: u8 = 0x04; diff --git a/fontique/src/lib.rs b/fontique/src/lib.rs index 9d41c424d..4642dd161 100644 --- a/fontique/src/lib.rs +++ b/fontique/src/lib.rs @@ -49,6 +49,8 @@ mod generic; mod impl_fontconfig; mod matching; mod scan; +#[cfg(feature = "std")] +mod scan_cache; mod script; mod source; diff --git a/fontique/src/scan.rs b/fontique/src/scan.rs index decc7abb2..4255d1a72 100644 --- a/fontique/src/scan.rs +++ b/fontique/src/scan.rs @@ -20,15 +20,16 @@ use read_fonts::{ }; use smallvec::SmallVec; #[cfg(feature = "std")] -use {super::source::SourcePathMap, std::path::Path}; -#[cfg(feature = "std")] use { + super::scan_cache, super::source::{SourceId, SourceInfo, SourceKind}, core::sync::atomic::{AtomicUsize, Ordering}, hashbrown::HashSet, std::path::PathBuf, std::sync::Arc, }; +#[cfg(feature = "std")] +use {super::source::SourcePathMap, std::path::Path}; use alloc::vec::Vec; @@ -47,7 +48,21 @@ impl ScannedCollection { /// Creates a new collection by scanning the given paths for /// font files. pub fn from_paths(paths: impl IntoIterator>, max_depth: u32) -> Self { - scan_collection(paths, max_depth) + scan_collection(paths, max_depth, None) + } + + /// Creates a new collection by scanning the given paths for font files, + /// using a persistent cache stored at `cache_path` to skip parsing + /// files that haven't changed since the last scan. + /// + /// The cache is created or refreshed as needed. If it can't be read or + /// written, the scan still succeeds without it. + pub fn from_paths_cached( + paths: impl IntoIterator>, + max_depth: u32, + cache_path: &Path, + ) -> Self { + scan_collection(paths, max_depth, Some(cache_path)) } } @@ -90,9 +105,13 @@ pub fn scan_memory<'a>(buf: &'a [u8], mut f: impl FnMut(&ScannedFont<'a>)) { fn scan_collection( paths: impl IntoIterator>, max_depth: u32, + cache_path: Option<&Path>, ) -> ScannedCollection { let files = collect_font_files(paths, max_depth); - let records = parse_files(&files); + let records = match cache_path { + Some(cache_path) => parse_files_cached(&files, cache_path), + None => parse_files(&files), + }; let mut collection = ScannedCollection::default(); let mut families: HashMap)> = HashMap::default(); for record in records { @@ -122,10 +141,10 @@ fn scan_collection( } #[cfg(feature = "std")] -struct FontRecord { - names: Vec, - postscript_name: String, - font: FontInfo, +pub(crate) struct FontRecord { + pub(crate) names: Vec, + pub(crate) postscript_name: String, + pub(crate) font: FontInfo, } /// Expands the given paths into a deduplicated list of files, walking @@ -164,31 +183,100 @@ fn collect_font_files( /// available CPU cores. #[cfg(feature = "std")] fn parse_files(files: &[PathBuf]) -> Vec { + parse_files_impl(files) + .into_iter() + .flat_map(|(_, records)| records) + .collect() +} + +/// 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 { + 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, Vec)> = + Vec::with_capacity(files.len()); + let mut misses: Vec<(&Path, Option)> = Vec::new(); + for path in files { + let stamp = 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)); + } + _ => misses.push((path.as_path(), stamp)), + } + } + let hit_count = results.len(); + + // Parse the files that missed the cache in parallel. + let miss_paths: Vec = misses + .iter() + .map(|(path, _)| (*path).to_path_buf()) + .collect(); + for (index, records) in parse_files_impl(&miss_paths) { + let (path, stamp) = misses[index]; + results.push((path, stamp, records)); + } + + // Refresh the cache if anything was parsed or any cached file no longer + // exists in the scanned set. + let stale_cached_files = cached_file_count - hit_count; + if !misses.is_empty() || stale_cached_files > 0 { + let _ = scan_cache::save( + cache_path, + results + .iter() + .filter_map(|(path, stamp, records)| Some((*path, (*stamp)?, records.as_slice()))), + ); + } + + results + .into_iter() + .flat_map(|(_, _, records)| records) + .collect() +} + +/// Reads and parses the given font files in parallel, returning the records +/// grouped by file (in arbitrary order). +#[cfg(feature = "std")] +fn parse_files_impl(files: &[PathBuf]) -> Vec<(usize, Vec)> { let num_threads = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(1) .min(files.len()); if num_threads <= 1 { - let mut records = Vec::new(); - for path in files { - parse_file(path, &mut records); - } - return records; + return files + .iter() + .enumerate() + .map(|(index, path)| { + let mut records = Vec::new(); + parse_file(path, &mut records); + (index, records) + }) + .collect(); } let cursor = AtomicUsize::new(0); std::thread::scope(|scope| { let handles: Vec<_> = (0..num_threads) .map(|_| { scope.spawn(|| { - let mut records = Vec::new(); + let mut results = Vec::new(); loop { let index = cursor.fetch_add(1, Ordering::Relaxed); let Some(path) = files.get(index) else { break; }; + let mut records = Vec::new(); parse_file(path, &mut records); + results.push((index, records)); } - records + results }) }) .collect(); @@ -545,3 +633,120 @@ fn english_or_first<'a>(names: &name::Name<'a>, id: NameId) -> Option Vec<(String, usize)> { + let mut summary: Vec<_> = collection + .families + .values() + .map(|family| (family.name().to_string(), family.fonts().len())) + .collect(); + summary.sort(); + summary + } + + fn font_key(font: &FontInfo) -> (PathBuf, u32) { + let SourceKind::Path(path) = font.source().kind() else { + unreachable!() + }; + (path.to_path_buf(), font.index()) + } + + fn sorted_fonts(collection: &ScannedCollection, family_name: &str) -> Vec { + let mut fonts: Vec = collection + .families + .values() + .find(|family| family.name() == family_name) + .unwrap() + .fonts() + .to_vec(); + fonts.sort_by_key(font_key); + fonts + } + + #[test] + fn cached_scan_matches_uncached() { + let dir = std::env::temp_dir().join(format!("fontique-scan-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts_dir = dir.join("fonts"); + std::fs::create_dir_all(&fonts_dir).unwrap(); + for asset in ASSETS { + let asset = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset); + std::fs::copy(&asset, fonts_dir.join(asset.file_name().unwrap())).unwrap(); + } + let cache_path = dir.join("cache.bin"); + + let uncached = ScannedCollection::from_paths([&fonts_dir], 2); + // Cold cache run (writes the cache). + let cold = ScannedCollection::from_paths_cached([&fonts_dir], 2, &cache_path); + assert!(cache_path.exists()); + // Warm cache run. + let warm = ScannedCollection::from_paths_cached([&fonts_dir], 2, &cache_path); + + assert_eq!(summarize(&uncached), summarize(&cold)); + assert_eq!(summarize(&uncached), summarize(&warm)); + let mut expected_ps: Vec<_> = uncached.postscript_names.keys().collect(); + let mut warm_ps: Vec<_> = warm.postscript_names.keys().collect(); + expected_ps.sort(); + warm_ps.sort(); + assert_eq!(expected_ps, warm_ps); + + // Every font attribute should round-trip through the cache. + for (family_name, _) in summarize(&uncached) { + let expected = sorted_fonts(&uncached, &family_name); + let cached = sorted_fonts(&warm, &family_name); + assert_eq!(expected.len(), cached.len()); + for (a, b) in expected.iter().zip(&cached) { + assert_eq!(a.index(), b.index()); + assert_eq!(a.width(), b.width()); + assert_eq!(a.style(), b.style()); + assert_eq!(a.weight(), b.weight()); + assert_eq!(a.charmap_index(), b.charmap_index()); + assert_eq!(a.axes().len(), b.axes().len()); + for (axis_a, axis_b) in a.axes().iter().zip(b.axes()) { + assert_eq!(axis_a.tag, axis_b.tag); + assert_eq!(axis_a.min, axis_b.min); + assert_eq!(axis_a.max, axis_b.max); + assert_eq!(axis_a.default, axis_b.default); + } + assert_eq!(a.has_weight_axis(), b.has_weight_axis()); + } + } + + // A changed file should be re-parsed: replace Roboto with Arimo + // (and ensure the modification time changes). + let replaced = fonts_dir.join("Roboto-Regular.ttf"); + let arimo = Path::new(env!("CARGO_MANIFEST_DIR")).join(ASSETS[1]); + std::fs::copy(&arimo, &replaced).unwrap(); + let old = std::time::SystemTime::now() - core::time::Duration::from_secs(1000); + std::fs::File::options() + .write(true) + .open(&replaced) + .unwrap() + .set_modified(old) + .unwrap(); + let rescanned = ScannedCollection::from_paths_cached([&fonts_dir], 2, &cache_path); + assert!( + !rescanned + .families + .values() + .any(|family| family.name() == "Roboto"), + "stale cache entry for changed file" + ); + assert_eq!(sorted_fonts(&rescanned, "Arimo").len(), 2); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/fontique/src/scan_cache.rs b/fontique/src/scan_cache.rs new file mode 100644 index 000000000..b0881cf72 --- /dev/null +++ b/fontique/src/scan_cache.rs @@ -0,0 +1,273 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Persistent cache for file system font scans. +//! +//! The cache stores, for every scanned file, the file's modification time +//! and size along with the font metadata extracted from it. On subsequent +//! scans, files whose modification time and size are unchanged can be +//! loaded from the cache instead of being read and parsed again. +//! +//! The format is a simple little-endian binary encoding with a magic number +//! and version. Any file that fails to decode (wrong magic, wrong version, +//! truncated data, etc.) is ignored, causing a full rescan that rewrites +//! the cache. + +#![cfg(feature = "std")] + +use super::font::{AxisInfo, AxisVec, FontInfo}; +use super::scan::FontRecord; +use super::source::{SourceId, SourceInfo, SourceKind}; +use crate::{CharmapIndex, FontStyle, FontWeight, FontWidth}; +use alloc::string::String; +use alloc::vec::Vec; +use hashbrown::HashMap; +use read_fonts::types::Tag; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +const MAGIC: &[u8; 4] = b"fqsc"; +const VERSION: u16 = 1; + +/// Modification time and size of a file, used to detect changes. +#[derive(Copy, Clone, PartialEq, Eq, Default)] +pub(crate) struct FileStamp { + mtime_secs: u64, + mtime_nanos: u32, + size: u64, +} + +impl FileStamp { + pub(crate) fn from_metadata(metadata: &std::fs::Metadata) -> Option { + let mtime = metadata + .modified() + .ok()? + .duration_since(SystemTime::UNIX_EPOCH) + .ok()?; + Some(Self { + mtime_secs: mtime.as_secs(), + mtime_nanos: mtime.subsec_nanos(), + size: metadata.len(), + }) + } +} + +/// Cached scan results for a single file. +pub(crate) struct CachedFile { + pub(crate) stamp: FileStamp, + pub(crate) records: Vec, +} + +/// Loads the cache from the given path. +/// +/// Returns `None` if the cache doesn't exist or can't be decoded. +pub(crate) fn load(path: &Path) -> Option> { + let data = std::fs::read(path).ok()?; + let mut reader = Reader { data: &data }; + if reader.bytes(4)? != MAGIC || reader.u16()? != VERSION { + return None; + } + let file_count = reader.u32()?; + let mut files = HashMap::with_capacity(file_count as usize); + for _ in 0..file_count { + let path = PathBuf::from(reader.str()?); + let stamp = FileStamp { + mtime_secs: reader.u64()?, + mtime_nanos: reader.u32()?, + size: reader.u64()?, + }; + let record_count = reader.u32()?; + let mut records = Vec::with_capacity(record_count.min(64) as usize); + let source: Arc = path.as_path().into(); + for _ in 0..record_count { + records.push(read_record(&mut reader, &source)?); + } + files.insert(path, CachedFile { stamp, records }); + } + Some(files) +} + +/// Saves the cache to the given path. +/// +/// The cache is written to a temporary file first and then renamed into +/// place so that concurrent readers never observe a partially written +/// cache. +pub(crate) fn save<'a>( + path: &Path, + files: impl Iterator, +) -> Option<()> { + let mut writer = Writer::default(); + writer.bytes(MAGIC); + writer.u16(VERSION); + let file_count_position = writer.data.len(); + writer.u32(0); + let mut file_count: u32 = 0; + for (file_path, stamp, records) in files { + let Some(path_str) = file_path.to_str() else { + continue; + }; + let Ok(record_count) = u32::try_from(records.len()) else { + continue; + }; + writer.str(path_str); + writer.u64(stamp.mtime_secs); + writer.u32(stamp.mtime_nanos); + writer.u64(stamp.size); + writer.u32(record_count); + for record in records { + write_record(&mut writer, record); + } + file_count = file_count.checked_add(1)?; + } + writer.data[file_count_position..file_count_position + 4] + .copy_from_slice(&file_count.to_le_bytes()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok()?; + } + let temp_path = path.with_extension(alloc::format!("tmp-{}", std::process::id())); + std::fs::write(&temp_path, &writer.data).ok()?; + std::fs::rename(&temp_path, path).ok() +} + +fn read_record(reader: &mut Reader<'_>, source_path: &Arc) -> Option { + let index = reader.u32()?; + let name_count = reader.u16()?; + let mut names = Vec::with_capacity(usize::from(name_count.min(16))); + for _ in 0..name_count { + names.push(String::from(reader.str()?)); + } + let postscript_name = String::from(reader.str()?); + let width = FontWidth::from_ratio(reader.f32()?); + let style = match reader.u8()? { + 0 => FontStyle::Normal, + 1 => FontStyle::Italic, + 2 => FontStyle::Oblique(None), + 3 => FontStyle::Oblique(Some(reader.f32()?)), + _ => return None, + }; + let weight = FontWeight::new(reader.f32()?); + let axis_count = reader.u16()?; + let mut axes = AxisVec::with_capacity(usize::from(axis_count.min(64))); + for _ in 0..axis_count { + axes.push(AxisInfo { + tag: Tag::new(reader.bytes(4)?.try_into().ok()?), + min: reader.f32()?, + max: reader.f32()?, + default: reader.f32()?, + }); + } + let charmap_index = + CharmapIndex::from_parts((reader.u32()?, reader.u8()? != 0, reader.u8()? != 0)); + let source = SourceInfo::new(SourceId::new(), SourceKind::Path(source_path.clone())); + Some(FontRecord { + names, + postscript_name, + font: FontInfo::from_parts(source, index, width, style, weight, axes, charmap_index), + }) +} + +fn write_record(writer: &mut Writer, record: &FontRecord) { + let font = &record.font; + writer.u32(font.index()); + writer.u16(record.names.len().min(u16::MAX as usize) as u16); + for name in record.names.iter().take(u16::MAX as usize) { + writer.str(name); + } + writer.str(&record.postscript_name); + writer.f32(font.width().ratio()); + match font.style() { + FontStyle::Normal => writer.u8(0), + FontStyle::Italic => writer.u8(1), + FontStyle::Oblique(None) => writer.u8(2), + FontStyle::Oblique(Some(angle)) => { + writer.u8(3); + writer.f32(angle); + } + } + writer.f32(font.weight().value()); + writer.u16(font.axes().len().min(u16::MAX as usize) as u16); + for axis in font.axes().iter().take(u16::MAX as usize) { + writer.bytes(&axis.tag.to_be_bytes()); + writer.f32(axis.min); + writer.f32(axis.max); + writer.f32(axis.default); + } + let (subtable_offset, is_symbol, is_mac_roman) = font.charmap_index().to_parts(); + writer.u32(subtable_offset); + writer.u8(u8::from(is_symbol)); + writer.u8(u8::from(is_mac_roman)); +} + +struct Reader<'a> { + data: &'a [u8], +} + +impl<'a> Reader<'a> { + fn bytes(&mut self, len: usize) -> Option<&'a [u8]> { + let (bytes, rest) = self.data.split_at_checked(len)?; + self.data = rest; + Some(bytes) + } + + fn u8(&mut self) -> Option { + Some(self.bytes(1)?[0]) + } + + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.bytes(2)?.try_into().ok()?)) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.bytes(4)?.try_into().ok()?)) + } + + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.bytes(8)?.try_into().ok()?)) + } + + fn f32(&mut self) -> Option { + Some(f32::from_le_bytes(self.bytes(4)?.try_into().ok()?)) + } + + fn str(&mut self) -> Option<&'a str> { + let len = self.u32()?; + core::str::from_utf8(self.bytes(usize::try_from(len).ok()?)?).ok() + } +} + +#[derive(Default)] +struct Writer { + data: Vec, +} + +impl Writer { + fn bytes(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + } + + fn u8(&mut self, value: u8) { + self.data.push(value); + } + + fn u16(&mut self, value: u16) { + self.bytes(&value.to_le_bytes()); + } + + fn u32(&mut self, value: u32) { + self.bytes(&value.to_le_bytes()); + } + + fn u64(&mut self, value: u64) { + self.bytes(&value.to_le_bytes()); + } + + fn f32(&mut self, value: f32) { + self.bytes(&value.to_le_bytes()); + } + + fn str(&mut self, value: &str) { + self.u32(value.len().min(u32::MAX as usize) as u32); + self.bytes(&value.as_bytes()[..value.len().min(u32::MAX as usize)]); + } +} From 149f4a507af8d6fe1e8e8dc3af939dae68cad990 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Wed, 5 Aug 2026 00:59:50 +0000 Subject: [PATCH 2/2] docs: changelog entry for font scan cache --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96df79d24..881ffcdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ This release has an [MSRV] of 1.88. #### Fontique - 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][]) ### Fixed @@ -599,6 +600,7 @@ This release has an [MSRV][] of 1.70. [#212]: https://github.com/linebender/parley/pull/212 [#213]: https://github.com/linebender/parley/pull/213 [#3]: https://github.com/DioxusLabs/parley/pull/3 +[#4]: https://github.com/DioxusLabs/parley/pull/4 [#215]: https://github.com/linebender/parley/pull/215 [#223]: https://github.com/linebender/parley/pull/223 [#224]: https://github.com/linebender/parley/pull/224