Fallback fonts: probe glyph coverage with ttf-parser before building the fontdue face
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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.
This commit is contained in:
2026-08-18 11:36:28 +02:00
parent 3046d86337
commit 902e23e7f2

View File

@@ -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<Option<FontHandle>> ]
/// 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<Vec<OnceLock<Option<FontHandle>>>> = OnceLock::new();
bytes: OnceLock<Option<Arc<Vec<u8>>>>,
font: OnceLock<Option<FontHandle>>,
}
fn slots() -> &'static [ Slot ]
{
static SLOTS: OnceLock<Vec<Slot>> = 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<FontHandle>` 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<Vec<u8>>` can be
/// handed to rustybuzz for shaping without re-reading the file.
fn slot_bytes( idx: usize ) -> Option<Arc<Vec<u8>>>
{
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<Vec<u8>>` can be handed
/// to rustybuzz for shaping without re-reading the file.
fn slot_handle( idx: usize ) -> Option<FontHandle>
{
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<FontHandle>
/// `Arc<Font>` 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<Arc<Font>>
{
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<FontHandle>
{
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 )
}