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.
This commit is contained in:
@@ -90,43 +90,61 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
|||||||
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
|
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Per-slot lazy state. `Vec` length matches
|
/// Per-slot lazy state, two stages so probing a slot for coverage never
|
||||||
/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves
|
/// pays the fontdue parse: `bytes` is the file read once, `font` is the
|
||||||
/// independently so the act of looking up a Devanagari codepoint
|
/// fontdue face built only when a glyph in this slot is actually needed.
|
||||||
/// doesn't drag the CJK pack into memory. `None` inside a resolved
|
/// `None` in a resolved stage is a sticky negative result.
|
||||||
/// slot means the file was missing or fontdue rejected it — a sticky
|
struct Slot
|
||||||
/// negative result so subsequent misses skip the slot in O(1).
|
|
||||||
fn slots() -> &'static [ OnceLock<Option<FontHandle>> ]
|
|
||||||
{
|
{
|
||||||
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( ||
|
SLOTS.get_or_init( ||
|
||||||
{
|
{
|
||||||
( 0..FALLBACK_FONT_CANDIDATES.len() )
|
( 0..FALLBACK_FONT_CANDIDATES.len() )
|
||||||
.map( |_| OnceLock::new() )
|
.map( |_| Slot { bytes: OnceLock::new(), font: OnceLock::new() } )
|
||||||
.collect()
|
.collect()
|
||||||
} )
|
} )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to load and parse the fallback font at slot `idx`. Each
|
fn slot_bytes( idx: usize ) -> Option<Arc<Vec<u8>>>
|
||||||
/// `OnceLock` wraps `Option<FontHandle>` so a missing or malformed
|
{
|
||||||
/// file is recorded as `None` and never re-attempted. The raw bytes
|
slots()[ idx ].bytes.get_or_init( ||
|
||||||
/// are preserved inside the handle so the same `Arc<Vec<u8>>` can be
|
{
|
||||||
/// handed to rustybuzz for shaping without re-reading the file.
|
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>
|
fn slot_handle( idx: usize ) -> Option<FontHandle>
|
||||||
{
|
{
|
||||||
let slot = &slots()[ idx ];
|
slots()[ idx ].font.get_or_init( ||
|
||||||
slot.get_or_init( ||
|
|
||||||
{
|
{
|
||||||
let spec = &FALLBACK_FONT_CANDIDATES[ idx ];
|
let bytes = slot_bytes( idx )?;
|
||||||
let bytes = std::fs::read( spec.path ).ok()?;
|
let face = FALLBACK_FONT_CANDIDATES[ idx ].face;
|
||||||
let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() };
|
let opts = FontSettings { collection_index: face, ..FontSettings::default() };
|
||||||
let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
|
let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
|
||||||
Some( FontHandle
|
Some( FontHandle { font: Arc::new( font ), bytes, face } )
|
||||||
{
|
|
||||||
font: Arc::new( font ),
|
|
||||||
bytes: Arc::new( bytes ),
|
|
||||||
face: spec.face,
|
|
||||||
} )
|
|
||||||
} )
|
} )
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
@@ -136,14 +154,6 @@ fn slot_handle( idx: usize ) -> Option<FontHandle>
|
|||||||
/// `Arc<Font>` for the rest of the process. Returns `None` if no
|
/// `Arc<Font>` for the rest of the process. Returns `None` if no
|
||||||
/// installed fallback covers the codepoint — the caller then paints
|
/// installed fallback covers the codepoint — the caller then paints
|
||||||
/// the primary font's `.notdef` rather than dropping the glyph.
|
/// 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>>
|
pub fn lookup( ch: char ) -> Option<Arc<Font>>
|
||||||
{
|
{
|
||||||
lookup_handle( ch ).map( |h| h.font )
|
lookup_handle( ch ).map( |h| h.font )
|
||||||
@@ -225,13 +235,7 @@ pub fn primary_handle() -> FontHandle
|
|||||||
/// without re-reading the font file.
|
/// without re-reading the font file.
|
||||||
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
|
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
|
||||||
{
|
{
|
||||||
for idx in 0..FALLBACK_FONT_CANDIDATES.len()
|
( 0..FALLBACK_FONT_CANDIDATES.len() )
|
||||||
{
|
.find( |&idx| slot_covers( idx, ch ) )
|
||||||
let Some( handle ) = slot_handle( idx ) else { continue };
|
.and_then( slot_handle )
|
||||||
if handle.font.lookup_glyph_index( ch ) != 0
|
|
||||||
{
|
|
||||||
return Some( handle );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user