From 7d5dd8520918f55c4bd61adb68d586a38484b345 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 13:39:33 -0600 Subject: [PATCH] UI-FONTS: continue fallback selection past ineligible faces --- ROADMAP.md | 1 + crates/fonts/src/fontdb.rs | 31 ++++++++++++++++++++++++++++--- crates/fonts/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/ui-egui/src/uifonts.rs | 22 ++++++++++++++++++++-- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..06e2efd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5425c022..8c788325 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1024,17 +1024,40 @@ impl FontDb { None } + /// A covering fallback accepted by the caller (for example, an interface that cannot use + /// colour or very large faces). Rejected faces do not stop the search for another face. + /// A policy-specific miss is not stored as a general document-font miss. + pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option> { + if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) { + return self.loaded_fallback_filtered(c, exclude, &accept); + } + None + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + self.loaded_fallback_filtered(c, exclude, &|_| true) + } + + fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() + order.into_iter().find(|f| f.covers(c) && accept(f)).cloned() } /// 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"))] pub(crate) fn system_fallback(&self, c: char) -> bool { + self.system_fallback_filtered(c, &|_| true, true) + } + + #[cfg(not(target_arch = "wasm32"))] + fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool { if c.is_control() || c.is_whitespace() { return false; } @@ -1044,7 +1067,7 @@ impl FontDb { return false; } } - let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); for fam in SYSTEM_FALLBACKS { if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { @@ -1068,7 +1091,9 @@ impl FontDb { return true; } } - self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + if remember_miss { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + } false } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 978661bb..79bcde85 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -189,6 +189,35 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn filtered_fallback_continues_after_a_rejected_loaded_face() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); + let first = db.fallback_for('A', u32::MAX).unwrap(); + let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; + let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); + assert_eq!(fallback.family, accepted_family); + assert!(fallback.covers('A')); + assert_ne!(fallback.id(), first.id()); + assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); + assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir]); + let first = db.fallback_for('A', u32::MAX).unwrap(); + assert_ne!(first.family, "DeckCraft Legacy"); + assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); + assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); + let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); + assert_eq!(accepted.family, "DeckCraft Legacy"); + assert!(accepted.covers('A')); + } + #[test] fn windows_family_names_find_their_faces() { // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs index 50ebc87b..e1297d9d 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -17,6 +17,10 @@ use deckcraft_fonts::{FontDb, FontFace}; /// Faces larger than this are left out (egui keeps a copy of each face it is given). const MAX_FACE_BYTES: usize = 48 << 20; +fn accepts_face(byte_len: usize, color: bool) -> bool { + !color && byte_len <= MAX_FACE_BYTES +} + /// Faces borrowed for the interface, and the characters already looked at. #[derive(Default)] pub struct UiFonts { @@ -51,7 +55,7 @@ pub fn faces_for_text( continue; } let Some(f) = lookup(c) else { continue }; - if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) { + if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) { continue; } out.push(f); @@ -67,7 +71,13 @@ impl UiFonts { } let font = egui::FontId::proportional(13.0); let new = ctx.fonts_mut(|f| { - faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX)) + faces_for_text( + text, + &self.faces, + &mut self.seen, + |c| f.has_glyph(&font, c), + |c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())), + ) }); if !new.is_empty() { log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::>().join(", ")); @@ -166,6 +176,14 @@ mod tests { FontDb::global().face(family, "Regular") } + #[test] + fn interface_face_policy_rejects_colour_and_oversize_faces() { + assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted"); + assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected"); + assert!(!accepts_face(1, true), "even a small colour face is still rejected"); + assert!(!accepts_face(MAX_FACE_BYTES + 1, true)); + } + /// One face for a whole string, looked up once per character, never for characters the /// interface already draws, and never twice. #[test]