From 0b39e287a40fd73ee311f52925b86e94e9ee2b1c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:38:19 -0500 Subject: [PATCH] Fonts: bound font-controlled work in enumeration, outlines and carets An embedded font could make each lookup do far more than real fonts need: - enumerating a collection read up to 256 faces times 1024 named instances, each then built with its own shaper; now 4096 per file across all faces, with a set for the duplicate-style check instead of a scan; - composite and VARC glyphs could expand one dense outline without limit, and the outline cache only counted entries; outlines now stop at 32768 path elements (drawing nothing past it) and the cache is also bounded by the elements it holds; - the 64-caret limit applied after filtering, so a ligature lookup could read every caret record; it now applies to the records read. --- crates/fonts/src/fallback.rs | 7 +- crates/fonts/src/fontdb.rs | 159 +++++++++++++++++++++++++++++++---- 2 files changed, 148 insertions(+), 18 deletions(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index de4b2b0a..8d54453f 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -227,6 +227,9 @@ impl FontDb { } } +/// Most caret records read for one ligature glyph. +const MAX_LIGATURE_CARETS: usize = 64; + /// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the /// glyph's origin (sorted ascending). Empty when the font has none for `gid`. pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { @@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { let Ok(cov) = list.coverage() else { return Vec::new() }; let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() }; let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() }; + // The limit applies to the records read, not to the carets kept: a font can list thousands of + // records it then has to be read through for every ligature laid out. let mut out: Vec = lig .caret_values() .iter() + .take(MAX_LIGATURE_CARETS) .filter_map(|cv| cv.ok()) .filter_map(|cv| match cv { skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32), @@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { // Format 2 is a contour point index; outlines aren't consulted here. skrifa::raw::tables::gdef::CaretValue::Format2(_) => None, }) - .take(64) .collect(); out.sort_unstable(); out diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2e2f3529..c1db0b35 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -368,7 +368,7 @@ struct CatalogEntry { /// Process-wide font database. pub struct FontDb { faces: RwLock>>, - outlines: Mutex>>, + outlines: Mutex, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). @@ -439,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[ static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a +/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes. +const MAX_OUTLINE_ELEMENTS: usize = 1 << 15; +/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it +/// starts over: distinct glyph ids can each expand one dense outline. +const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22; + +/// Glyph outlines by (face, glyph), with the path elements they hold. +#[derive(Default)] +struct OutlineCache { + paths: HashMap<(u32, u32), Arc>, + elements: usize, +} + +impl OutlineCache { + fn get(&self, key: &(u32, u32)) -> Option> { + self.paths.get(key).cloned() + } + + fn insert(&mut self, key: (u32, u32), path: Arc) { + let n = path.elements().len(); + if let Some(old) = self.paths.remove(&key) { + self.elements = self.elements.saturating_sub(old.elements().len()); + } + if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS { + self.paths.clear(); + self.elements = 0; + } + self.paths.insert(key, path); + self.elements = self.elements.saturating_add(n); + } +} fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) @@ -694,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256; const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384; /// Maximum named instances read from a single face. const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024; +/// Maximum faces (named instances counted separately) enumerated from one font file, across all +/// of a collection's faces: the per-collection and per-face limits multiply, and each face found +/// is then built with its own shaper. +const MAX_ENUMERATED_FACES: usize = 4096; fn distinct_face_indices(entries: impl IntoIterator) -> Vec { let mut offsets = std::collections::HashSet::new(); @@ -730,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec { Err(_) => Vec::new(), }; let mut out = Vec::new(); + // Named instances read and plain faces kept, across the whole file. + let mut budget = MAX_ENUMERATED_FACES; for i in indices { + if budget == 0 { + break; + } let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; let Some((family, style)) = face_names(&f) else { continue }; let axes = f.axes(); let mut named = Vec::new(); + let mut seen = std::collections::HashSet::new(); let instances = f.named_instances(); for ni in instances.iter().take(capped_named_instance_count(instances.len())) { + let Some(left) = budget.checked_sub(1) else { break }; + budget = left; let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; - if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { + if !seen.insert(style.to_ascii_lowercase()) { continue; } let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); named.push((i, style, coords)); } if named.is_empty() { + budget = budget.saturating_sub(1); out.push((i, family, style, Vec::new())); } else { out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); @@ -905,7 +950,7 @@ impl FontDb { } Self { faces: RwLock::new(faces), - outlines: Mutex::new(HashMap::new()), + outlines: Mutex::new(OutlineCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), font_dirs, @@ -1513,41 +1558,72 @@ impl FontDb { pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return p.clone(); + return p; } - let mut pen = FlipPen(BezPath::new()); + let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS); if let Some(f) = face.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); } - let p = Arc::new(pen.0); - let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if cache.len() >= OUTLINE_CACHE_MAX { - cache.clear(); - } - cache.insert(key, p.clone()); + let p = Arc::new(pen.finish()); + self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone()); p } } -struct FlipPen(BezPath); +/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores +/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly. +struct FlipPen { + path: BezPath, + limit: usize, + over: bool, +} + +impl FlipPen { + fn new(limit: usize) -> Self { + FlipPen { path: BezPath::new(), limit, over: false } + } + + /// Room for one more element? + fn room(&mut self) -> bool { + if !self.over && self.path.elements().len() >= self.limit { + self.over = true; + self.path = BezPath::new(); + } + !self.over + } + + fn finish(self) -> BezPath { + if self.over { BezPath::new() } else { self.path } + } +} impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { - self.0.move_to((x as f64, -y as f64)); + if self.room() { + self.path.move_to((x as f64, -y as f64)); + } } fn line_to(&mut self, x: f32, y: f32) { - self.0.line_to((x as f64, -y as f64)); + if self.room() { + self.path.line_to((x as f64, -y as f64)); + } } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { - self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + if self.room() { + self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + if self.room() { + self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } } fn close(&mut self) { - self.0.close_path(); + if self.room() { + self.path.close_path(); + } } } @@ -1575,6 +1651,55 @@ mod font_dir_tests { assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE); } + #[test] + fn faces_of_one_file_are_bounded_across_the_collection() { + // The per-collection and per-face limits together would allow far more. + assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES); + for data in bundled() { + assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES); + } + } + + #[test] + fn an_outline_past_the_element_limit_draws_nothing() { + let mut pen = FlipPen::new(8); + pen.move_to(0.0, 0.0); + pen.line_to(1.0, 1.0); + pen.close(); + assert_eq!(pen.finish().elements().len(), 3); + // A shared outline drawn over and over (composite and VARC glyphs). + let mut pen = FlipPen::new(8); + for i in 0..1_000_000 { + pen.move_to(0.0, 0.0); + pen.line_to(i as f32, 1.0); + pen.close(); + } + assert_eq!(pen.path.elements().len(), 0); + assert!(pen.finish().elements().is_empty()); + } + + #[test] + fn outline_cache_is_bounded_by_elements() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + for i in 1..MAX_OUTLINE_ELEMENTS { + path.line_to((i as f64, 0.0)); + } + let path = Arc::new(path); + let mut cache = OutlineCache::default(); + let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS; + for gid in 0..glyphs as u32 { + cache.insert((1, gid), path.clone()); + assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS); + assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::()); + } + assert!(cache.get(&(1, glyphs as u32 - 1)).is_some()); + // Replacing an entry does not count it twice. + let before = cache.elements; + cache.insert((1, glyphs as u32 - 1), path.clone()); + assert_eq!(cache.elements, before); + } + #[test] fn xdg_data_dirs_contribute_their_fonts_folders() { let home = Path::new("/home/ann");