From 3c495a7b611d14169b85b2e80f4e9676c3a20e8f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:22:05 +0000 Subject: [PATCH 1/4] Fonts: the file search for an uncovered character stops at a budget between scans --- crates/text/src/fontdb.rs | 11 +++++++++++ crates/text/src/fontdb/db.rs | 1 + crates/text/src/fontdb/lookup.rs | 7 +++++++ crates/text/src/fontdb/names.rs | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a95ac2dc5d..bad992e513 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -192,6 +192,17 @@ mod alias_tests { font } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_file_search_for_uncovered_characters_stops_at_its_budget() { + let db = FontDb::with_font_dirs(vec![]); + db.sys.lock().unwrap().file_searches = names::MAX_FILE_SEARCHES; + assert!(!db.system_fallback('\u{E000}')); + let sys = db.sys.lock().unwrap(); + assert_eq!(sys.file_searches, names::MAX_FILE_SEARCHES); + assert!(!sys.misses.contains(&'\u{E000}'), "an unsearched character is not a remembered miss"); + } + #[test] fn a_collection_lists_at_most_max_faces() { let mut font = name_font(&[(3, 0x0409, 1, "Fam"), (3, 0x0409, 2, "Regular")]); diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 902a3e5aae..4ef17c40c6 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -456,6 +456,7 @@ impl FontDb { let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); sys.scans = sys.scans.wrapping_add(1); sys.misses.clear(); + sys.file_searches = 0; } self.changed(); n diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 71e8e7ab88..18af244ed1 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -501,6 +501,13 @@ impl FontDb { return true; } } + { + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.file_searches >= super::names::MAX_FILE_SEARCHES { + return false; + } + sys.file_searches += 1; + } let mut paths: Vec = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect(); paths.sort(); paths.dedup(); diff --git a/crates/text/src/fontdb/names.rs b/crates/text/src/fontdb/names.rs index 6ed1b44a2c..7531b130ec 100644 --- a/crates/text/src/fontdb/names.rs +++ b/crates/text/src/fontdb/names.rs @@ -7,8 +7,16 @@ pub(super) struct SysFallback { pub(super) misses: std::collections::HashSet, /// Counts the scans of the font folders: a search that a scan overtook doesn't record its miss. pub(super) scans: u64, + /// Characters that went on to read every cataloged font file (and found nothing, or a face). + /// Each such search rereads the catalog, so only [`MAX_FILE_SEARCHES`] are made per scan. + pub(super) file_searches: u32, } +/// The most characters searched for in the font files themselves between two scans of the font +/// folders. Real text has a few uncovered characters; a document of thousands of distinct +/// private-use code points would reread the whole catalog for each. +pub(super) const MAX_FILE_SEARCHES: u32 = 1024; + /// Fonts made for Thai, tried first (when installed) for Thai the font in use lacks: Windows /// (Leelawadee UI; Tahoma before Windows 8), macOS (Thonburi, Sukhumvit Set) and Linux (Noto, /// TLWG). From 21e6524f0627ea1d75f8cfb6a87094493ff65eb0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:24:43 +0000 Subject: [PATCH 2/4] Text: colour layers stop drawing outlines once over budget, one frame's baseline search has a total step budget, a glyph outline keeps at most a million elements --- crates/text/src/colr.rs | 17 +++++++++--- crates/text/src/fontdb/glyph_pen.rs | 40 +++++++++++++++++++++++++++++ crates/text/src/layout/breaking.rs | 36 ++++++++++++++++++++++++++ crates/text/src/layout/flow.rs | 1 + 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 9045a4f550..a3c6bf6e80 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -169,6 +169,17 @@ impl<'a> Tree<'a> { } } + /// Glyph `gid`'s outline in the current transform; empty once the glyph is over budget, so + /// the layers that follow cost nothing (a compact layer list can name one big outline often). + fn layer_outline(&mut self, gid: GlyphId) -> BezPath { + if self.too_large { + return BezPath::new(); + } + let mut path = outline(self.font, self.location, gid); + path.apply_affine(self.transform()); + path + } + fn transform(&self) -> Affine { self.transforms.last().copied().unwrap_or(FLIP) } @@ -359,8 +370,7 @@ impl ColorPainter for Tree<'_> { } fn push_clip_glyph(&mut self, glyph_id: GlyphId) { - let mut clip = outline(self.font, self.location, glyph_id); - clip.apply_affine(self.transform()); + let clip = self.layer_outline(glyph_id); self.charge(&clip); self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false }); } @@ -392,8 +402,7 @@ impl ColorPainter for Tree<'_> { /// The glyph's outline filled, the brush moved by `brush_transform` (the glyph stays). fn fill_glyph(&mut self, glyph_id: GlyphId, brush_transform: Option, brush: Brush<'_>) { - let mut path = outline(self.font, self.location, glyph_id); - path.apply_affine(self.transform()); + let path = self.layer_outline(glyph_id); let brush_xf = self.transform() * brush_transform.as_ref().map_or(Affine::IDENTITY, affine); self.transforms.push(brush_xf); self.fill_path(&path, &brush); diff --git a/crates/text/src/fontdb/glyph_pen.rs b/crates/text/src/fontdb/glyph_pen.rs index 7f02517037..fdbb27e3a7 100644 --- a/crates/text/src/fontdb/glyph_pen.rs +++ b/crates/text/src/fontdb/glyph_pen.rs @@ -7,23 +7,63 @@ pub(super) fn svg_drawn(face: &FontFace, font: &skrifa::FontRef<'_>, gid: u32) - face.svg_glyphs && !crate::colr::has_colour_glyph(font, gid) } +/// The most path elements one glyph outline keeps. Real outlines have hundreds; a variable +/// composite glyph can repeat a big base outline into millions. Elements past it are dropped. +pub(crate) const MAX_OUTLINE_ELEMENTS: usize = 1 << 20; + /// A pen drawing outlines y down, as text space has them. pub(crate) struct FlipPen(pub(crate) BezPath); +impl FlipPen { + fn full(&self) -> bool { + self.0.elements().len() >= MAX_OUTLINE_ELEMENTS + } +} + impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { + if self.full() { + return; + } self.0.move_to((x as f64, -y as f64)); } fn line_to(&mut self, x: f32, y: f32) { + if self.full() { + return; + } self.0.line_to((x as f64, -y as f64)); } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + if self.full() { + return; + } self.0.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) { + if self.full() { + return; + } self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); } fn close(&mut self) { + if self.full() { + return; + } self.0.close_path(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pen_keeps_at_most_the_element_cap() { + let mut pen = FlipPen(BezPath::new()); + pen.move_to(0.0, 0.0); + for i in 0..MAX_OUTLINE_ELEMENTS + 10 { + pen.line_to(i as f32, 0.0); + } + assert_eq!(pen.0.elements().len(), MAX_OUTLINE_ELEMENTS); + } +} diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 87fa8db4df..a753dba779 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -63,12 +63,19 @@ pub(super) struct Pen<'r> { pub(super) next_top: Option, /// The narrowest word no line from here down is wide enough for (see [`Pen::place`]). pub(super) no_room_for: f64, + /// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's + /// copies): see [`MAX_TOTAL_STEPS`]. + pub(super) steps_left: std::rc::Rc>, } /// The largest number of steps layout takes to move a line down a frame cell to find room for it: /// in a cell taller than this many leads, a step is the cell's height divided by this. const MAX_STEPS: usize = 100_000; +/// The most baseline steps one frame's layout takes in all, over every cell: a cell may take +/// [`MAX_STEPS`], but rows and columns of tall, narrow-shaped cells would multiply that. +pub(super) const MAX_TOTAL_STEPS: usize = 10_000_000; + impl Pen<'_> { /// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts /// with a word `word` wide; `None` = the frame is full (overflow). @@ -110,6 +117,12 @@ impl Pen<'_> { // height when that is more, in at most `MAX_STEPS` steps. let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64); for _ in 0..=MAX_STEPS { + let left = self.steps_left.get(); + if left == 0 { + // Out of steps: the frame is full as far as this text goes. + return None; + } + self.steps_left.set(left - 1); // A baseline a step no longer moves (past a gutter of 1e300, say) won't reach a line. if baseline + est.desc > r.bottom() + 0.01 || baseline + step <= baseline || baseline.is_nan() { break; @@ -732,3 +745,26 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio v.dedup_by_key(|b| b.end); v } + +#[cfg(test)] +#[test] +fn a_pen_out_of_baseline_steps_finds_no_room() { + let frame = kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, 400.0, 400.0), 0.1); + let regions = Region::cells(&frame, &super::LayoutOptions::default(), &[]); + let est = Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 }; + let pen = |steps| Pen { + regions: Some(®ions), + ri: 0, + prev: None, + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + model: LeadingModel::default(), + next_top: None, + no_room_for: f64::INFINITY, + steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)), + }; + assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some()); + assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none()); +} diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 56c71c2f4b..670384b1d7 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -150,6 +150,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg model: t.para_at(0).leading_model, next_top: None, no_room_for: f64::INFINITY, + steps_left: std::rc::Rc::new(std::cell::Cell::new(super::breaking::MAX_TOTAL_STEPS)), }; let markers = list_markers(t, paras); for (pi, pr) in paras.iter().enumerate() { From f64438122f617648ef6d8786599a64bf46507895 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:48:51 +0000 Subject: [PATCH 3/4] Fonts: system_fallback visible to the module's tests --- crates/text/src/fontdb/lookup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 18af244ed1..fa91e97875 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -482,7 +482,7 @@ impl FontDb { /// Load a system font covering `c` (preferred fallback families first, then any cataloged /// file under 40 MB). Returns true if one was loaded. Misses are remembered. #[cfg(not(target_arch = "wasm32"))] - fn system_fallback(&self, c: char) -> bool { + pub(super) fn system_fallback(&self, c: char) -> bool { if c.is_control() || c.is_whitespace() { return false; } From 56142af888730e315b17a2f4a9ceb638f793b3be Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:54:53 +0000 Subject: [PATCH 4/4] Fonts: the budget test starts after the first scan --- crates/text/src/fontdb.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index bad992e513..12d27bd119 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -196,6 +196,8 @@ mod alias_tests { #[test] fn the_file_search_for_uncovered_characters_stops_at_its_budget() { let db = FontDb::with_font_dirs(vec![]); + // The first search scans the font folders, which starts the count again. + db.system_fallback('\u{E001}'); db.sys.lock().unwrap().file_searches = names::MAX_FILE_SEARCHES; assert!(!db.system_fallback('\u{E000}')); let sys = db.sys.lock().unwrap();