From 902e23e7f2e0a945f6b0c92bda44d0866b162668 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 18 Aug 2026 11:36:28 +0200 Subject: [PATCH] Fallback fonts: probe glyph coverage with ttf-parser before building the fontdue face The first string containing a glyph outside Sora stalled the caller for over a second, even on a desktop i7. `system_fonts::lookup_handle` walked the fallback chain building a `fontdue::Font` for every slot just to consult its `cmap`, and fontdue parses the whole face up front: reaching a dingbat such as U+2733 meant paying for `NotoSansCJK-Regular.ttc` (19 MB) although the glyph is not even there and only DejaVu, the last slot, carries it. In forge that showed as the app switcher taking more than a second to appear the first time a window title carried such a symbol; the top bar never triggered it because its text is all Latin. Each slot now has two lazily resolved stages: the file bytes, read once, and the fontdue face. Coverage is answered from the bytes alone with `rustybuzz::ttf_parser::Face::parse(...).glyph_index(ch)`, which only touches the tables it is asked for, and the fontdue face is built solely for the slot that actually owns the glyph. A CJK codepoint still pays the CJK parse, since it has to be rasterised, but a symbol that lives in DejaVu no longer drags the collection into memory. The sticky negative results are kept at both stages, and the shaping-side `bytes` in `FontHandle` now share the `Arc` with the coverage stage instead of a second copy. --- src/system_fonts.rs | 88 +++++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/src/system_fonts.rs b/src/system_fonts.rs index c211b25..7d75c42 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -90,43 +90,61 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] = FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 }, ]; -/// Per-slot lazy state. `Vec` length matches -/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves -/// independently so the act of looking up a Devanagari codepoint -/// doesn't drag the CJK pack into memory. `None` inside a resolved -/// slot means the file was missing or fontdue rejected it — a sticky -/// negative result so subsequent misses skip the slot in O(1). -fn slots() -> &'static [ OnceLock> ] +/// Per-slot lazy state, two stages so probing a slot for coverage never +/// pays the fontdue parse: `bytes` is the file read once, `font` is the +/// fontdue face built only when a glyph in this slot is actually needed. +/// `None` in a resolved stage is a sticky negative result. +struct Slot { - static SLOTS: OnceLock>>> = OnceLock::new(); + bytes: OnceLock>>>, + font: OnceLock>, +} + +fn slots() -> &'static [ Slot ] +{ + static SLOTS: OnceLock> = OnceLock::new(); SLOTS.get_or_init( || { ( 0..FALLBACK_FONT_CANDIDATES.len() ) - .map( |_| OnceLock::new() ) + .map( |_| Slot { bytes: OnceLock::new(), font: OnceLock::new() } ) .collect() } ) } -/// Try to load and parse the fallback font at slot `idx`. Each -/// `OnceLock` wraps `Option` so a missing or malformed -/// file is recorded as `None` and never re-attempted. The raw bytes -/// are preserved inside the handle so the same `Arc>` can be -/// handed to rustybuzz for shaping without re-reading the file. +fn slot_bytes( idx: usize ) -> Option>> +{ + slots()[ idx ].bytes.get_or_init( || + { + std::fs::read( FALLBACK_FONT_CANDIDATES[ idx ].path ).ok().map( Arc::new ) + } ) + .clone() +} + +/// Whether slot `idx` owns a glyph for `ch`, answered from the `cmap` +/// alone: ttf-parser only touches the tables it is asked for, so this +/// stays cheap even for a multi-megabyte CJK collection. +fn slot_covers( idx: usize, ch: char ) -> bool +{ + let Some( bytes ) = slot_bytes( idx ) else { return false }; + let face = FALLBACK_FONT_CANDIDATES[ idx ].face; + rustybuzz::ttf_parser::Face::parse( &bytes, face ) + .ok() + .and_then( |f| f.glyph_index( ch ) ) + .is_some() +} + +/// Build (once) the fontdue face for slot `idx`. The raw bytes are +/// preserved inside the handle so the same `Arc>` can be handed +/// to rustybuzz for shaping without re-reading the file. fn slot_handle( idx: usize ) -> Option { - let slot = &slots()[ idx ]; - slot.get_or_init( || + slots()[ idx ].font.get_or_init( || { - let spec = &FALLBACK_FONT_CANDIDATES[ idx ]; - let bytes = std::fs::read( spec.path ).ok()?; - let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() }; + let bytes = slot_bytes( idx )?; + let face = FALLBACK_FONT_CANDIDATES[ idx ].face; + let opts = FontSettings { collection_index: face, ..FontSettings::default() }; let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?; - Some( FontHandle - { - font: Arc::new( font ), - bytes: Arc::new( bytes ), - face: spec.face, - } ) + Some( FontHandle { font: Arc::new( font ), bytes, face } ) } ) .clone() } @@ -136,14 +154,6 @@ fn slot_handle( idx: usize ) -> Option /// `Arc` for the rest of the process. Returns `None` if no /// installed fallback covers the codepoint — the caller then paints /// the primary font's `.notdef` rather than dropping the glyph. -/// -/// Side effect: walking the chain may load and cache a slot even if -/// it doesn't end up covering `ch` (`lookup_glyph_index` reads the -/// `cmap` table, which requires the font to be parsed). That's -/// acceptable — the slot is cached on the first encounter regardless, -/// and most coverage gaps in early slots are the small Noto Sans -/// scripts (Devanagari, Arabic, …) whose total weight is a fraction -/// of the CJK pack everyone was paying for unconditionally. pub fn lookup( ch: char ) -> Option> { lookup_handle( ch ).map( |h| h.font ) @@ -225,13 +235,7 @@ pub fn primary_handle() -> FontHandle /// without re-reading the font file. pub fn lookup_handle( ch: char ) -> Option { - for idx in 0..FALLBACK_FONT_CANDIDATES.len() - { - let Some( handle ) = slot_handle( idx ) else { continue }; - if handle.font.lookup_glyph_index( ch ) != 0 - { - return Some( handle ); - } - } - None + ( 0..FALLBACK_FONT_CANDIDATES.len() ) + .find( |&idx| slot_covers( idx, ch ) ) + .and_then( slot_handle ) }