Fonts: index system font coverage once, so fallback no longer re-reads every font per character (#605) (#610)

System fallback read and parsed every cataloged font file (<40 MB) again for each distinct
character no loaded font covers; only that character's miss was remembered. Text in a script
no installed font covers (Tangut, CJK Ext G, ...) froze layout for seconds to minutes.

Each file is now read once, when a fallback search first reaches it, and its coverage is kept
as sorted code point ranges; later searches answer from the ranges and only read a file that
maps the character (to load it). Rescans keep the ranges of files still installed. Preferred
fallback families are tried once each, the miss set is capped, and indexing a damaged file is
bounded (faces, mappings, ranges). Search order and the fonts chosen are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 03:24:48 +02:00 committed by GitHub
parent f422d6ccbb
commit b2c51c6500
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 235 additions and 17 deletions

View File

@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) |

View File

@ -292,6 +292,13 @@ pub struct FontDb {
/// System fallback state: enabled, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
/// The characters each installed font file maps, read the first time a fallback search
/// needs that file. Held through a search, so two searches never read a file twice.
#[cfg(not(target_arch = "wasm32"))]
coverage: Mutex<CoverageIndex>,
/// Counts the font folder scans, so the coverage index notices a rescan.
#[cfg(not(target_arch = "wasm32"))]
catalog_gen: AtomicU32,
}
#[cfg(not(target_arch = "wasm32"))]
@ -299,6 +306,115 @@ pub struct FontDb {
struct SysFallback {
enabled: bool,
misses: std::collections::HashSet<char>,
/// Preferred fallback families already loaded (or tried) by a fallback search.
tried: std::collections::HashSet<&'static str>,
}
/// Characters remembered as covered by no installed font (a cap on memory: once full the set
/// starts over, and the coverage index answers again quickly).
#[cfg(not(target_arch = "wasm32"))]
const SYS_MISSES_MAX: usize = 65_536;
/// Installed font files larger than this are not searched for fallback characters.
#[cfg(not(target_arch = "wasm32"))]
const SYS_FALLBACK_MAX_FILE: u64 = 40 << 20;
/// Which characters each cataloged font file maps (#605). Before it, every distinct character no
/// loaded font covered read and parsed every installed font file again: 500 distinct Tangut
/// characters froze the app for most of a minute. Now each file is read once, when a search
/// first gets to it, and later searches answer from its ranges, reading only a file that matches.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct CoverageIndex {
/// The catalog scan the file list was taken from (`None` until the first search).
generation: Option<u32>,
/// The cataloged font files, sorted.
files: Vec<IndexedFile>,
/// Font files read by fallback searches (each at most once to index it, once more to load it).
reads: usize,
}
#[cfg(not(target_arch = "wasm32"))]
struct IndexedFile {
path: std::path::PathBuf,
/// The code points the file's faces map, as sorted, disjoint inclusive ranges (`None` until
/// the file is read; empty when it can't be read or is too large).
ranges: Option<Box<[(u32, u32)]>>,
/// Loaded for a character it covers: its faces are searched as loaded fonts from then on.
used: bool,
}
/// Does `ranges` (sorted, disjoint, inclusive) hold `cp`?
#[cfg(not(target_arch = "wasm32"))]
fn ranges_hold(ranges: &[(u32, u32)], cp: u32) -> bool {
let i = ranges.partition_point(|r| r.1 < cp);
ranges.get(i).is_some_and(|r| r.0 <= cp)
}
/// The code points mapped by the faces of `data` (a font file or collection), as sorted,
/// disjoint, inclusive ranges. Bounded however damaged the file: at most 256 faces, a budget of
/// mappings read across them, and a cap on the ranges kept.
#[cfg(not(target_arch = "wasm32"))]
fn coverage_ranges(data: &[u8]) -> Box<[(u32, u32)]> {
const MAX_FACES: u32 = 256;
const MAX_MAPPINGS: usize = 4 * 0x11_0000;
const MAX_RANGES: usize = 1 << 17;
fn compact(r: &mut Vec<(u32, u32)>) {
r.sort_unstable();
let mut out: Vec<(u32, u32)> = Vec::with_capacity(r.len());
for &(s, e) in r.iter() {
match out.last_mut() {
Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
_ => out.push((s, e)),
}
}
*r = out;
}
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES),
Err(_) => 0,
};
let mut ranges: Vec<(u32, u32)> = Vec::new();
let mut budget = MAX_MAPPINGS;
'faces: for i in 0..count {
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
for (cp, _) in f.charmap().mappings() {
budget = match budget.checked_sub(1) {
Some(b) => b,
None => break 'faces,
};
match ranges.last_mut() {
Some(last) if last.1.checked_add(1) == Some(cp) => last.1 = cp,
Some(last) if last.0 <= cp && cp <= last.1 => {}
_ => {
if ranges.len() >= MAX_RANGES {
compact(&mut ranges);
if ranges.len() >= MAX_RANGES {
break 'faces;
}
}
ranges.push((cp, cp));
}
}
}
}
compact(&mut ranges);
ranges.into_boxed_slice()
}
/// An installed font file's bytes, `None` when it can't be read or is larger than
/// [`SYS_FALLBACK_MAX_FILE`] (also when it grew past that since it was cataloged).
#[cfg(not(target_arch = "wasm32"))]
fn read_fallback_file(path: &std::path::Path) -> Option<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > SYS_FALLBACK_MAX_FILE {
return None;
}
let mut data = Vec::new();
file.take(SYS_FALLBACK_MAX_FILE + 1).read_to_end(&mut data).ok()?;
(data.len() as u64 <= SYS_FALLBACK_MAX_FILE).then_some(data)
}
/// Families preferred for right-to-left scripts (Persian, Arabic, Urdu, Hebrew) when the
@ -705,6 +821,10 @@ impl FontDb {
loading: Mutex::new(()),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
#[cfg(not(target_arch = "wasm32"))]
coverage: Mutex::new(CoverageIndex::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog_gen: AtomicU32::new(0),
}
}
@ -927,6 +1047,7 @@ impl FontDb {
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
self.catalog_gen.fetch_add(1, Ordering::AcqRel);
n
}
@ -1157,7 +1278,8 @@ 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.
/// file under 40 MB, found through the coverage index). Returns true if one was loaded.
/// Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback(&self, c: char) -> bool {
if c.is_control() || c.is_whitespace() {
@ -1174,27 +1296,76 @@ impl FontDb {
let rtl = crate::is_rtl(c) || arabic_block(c);
let preferred = RTL_FALLBACKS.iter().filter(|_| rtl);
for fam in preferred.chain(SYSTEM_FALLBACKS.iter()) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
if covered(self) {
return true;
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
// Each family is tried once: one that fails to load isn't read again per character.
if self.is_loaded(fam) || !cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|f| skrifa::FontRef::from_index(&data, f.index).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font(data) > 0 && covered(self) {
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).tried.insert(fam) {
continue;
}
self.load_cataloged(fam);
if covered(self) {
return true;
}
}
if self.indexed_fallback(c) {
return true;
}
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if sys.misses.len() >= SYS_MISSES_MAX {
sys.misses.clear();
}
sys.misses.insert(c);
false
}
/// Load the first cataloged font file (in path order) that maps `c`, reading each file not
/// yet indexed as the search reaches it. Returns whether a loaded face now covers `c`.
#[cfg(not(target_arch = "wasm32"))]
fn indexed_fallback(&self, c: char) -> bool {
let cp = u32::from(c);
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
let generation = self.catalog_gen.load(Ordering::Acquire);
if index.generation != Some(generation) {
// A new scan (or the first search): keep what is known of files still installed.
let mut known: HashMap<std::path::PathBuf, IndexedFile> =
std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect();
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
index.files = paths.into_iter().map(|path| known.remove(&path).unwrap_or(IndexedFile { path, ranges: None, used: false })).collect();
index.generation = Some(generation);
}
let CoverageIndex { files, reads, .. } = &mut *index;
for file in files.iter_mut().filter(|f| !f.used) {
let mut data = None;
match &file.ranges {
Some(r) if !ranges_hold(r, cp) => continue,
Some(_) => {}
None => {
*reads += 1;
data = read_fallback_file(&file.path);
let ranges = data.as_deref().map(coverage_ranges).unwrap_or_default();
let hit = ranges_hold(&ranges, cp);
file.ranges = Some(ranges);
if !hit {
continue;
}
}
}
// The file maps `c`: load it (once; its faces are loaded fonts from now on).
file.used = true;
if data.is_none() {
*reads += 1;
data = read_fallback_file(&file.path);
}
if let Some(data) = data
&& self.add_font(data) > 0
&& self.read_faces().iter().any(|f| f.covers(c))
{
return true;
}
}
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
false
}

View File

@ -250,6 +250,52 @@ fn fallback_cache_is_dropped_when_a_face_loads_by_its_legacy_family_name() {
assert!(!cache.map.contains_key(&(thai, latin.id())), "answers from before the load are dropped");
}
#[test]
fn fallback_reads_each_installed_font_file_once_however_many_characters_miss() {
// #605: every distinct character no font covers read every installed font file again.
let dir = font_dir("coverage");
// The installed Regular also maps U+30000 (no bundled font does): its last cmap group, the
// single character U+1F916, moved there (still the highest, so the groups stay sorted).
let mut regular = renamed("SourceSans3-Regular.ttf");
let (from, to) = ([0x0001_F916_u32.to_be_bytes(); 2].concat(), [0x0003_0000_u32.to_be_bytes(); 2].concat());
let mut patched = 0;
while let Some(at) = regular.windows(from.len()).position(|w| w == from) {
regular[at..at + to.len()].copy_from_slice(&to);
patched += 1;
}
assert!(patched > 0);
std::fs::write(dir.join("Sysfont-Regular.ttf"), regular).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let latin = db.face(FALLBACK_FAMILY, "Regular");
let found = db.fallback_for('\u{30000}', latin.id()).expect("the installed font maps it");
assert_eq!((found.family.as_str(), found.style.as_str()), (FAMILY, "Regular"));
// 2,000 distinct characters nothing maps: the index answers, no file is read again.
for cp in 0x30001..0x30001 + 2000 {
let c = char::from_u32(cp).unwrap();
assert!(db.fallback_for(c, latin.id()).is_none());
}
let index = db.coverage.lock().unwrap();
assert_eq!(index.files.len(), 2, "Regular and Bold are cataloged");
assert_eq!(index.reads, 2, "each file read once");
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
let ranges = coverage_ranges(&data);
assert!(ranges.windows(2).all(|w| w[0].1 + 1 < w[1].0), "sorted, disjoint and merged");
let font = skrifa::FontRef::new(&data).unwrap();
let mapped: Vec<u32> = font.charmap().mappings().map(|(cp, _)| cp).collect();
assert!(ranges.len() < mapped.len() / 4, "ranges, not a set of characters");
assert!(mapped.iter().all(|cp| ranges_hold(&ranges, *cp)));
for cp in [0x0, 0x9, 0x0E01, 0x17000, 0x10_FFFF] {
assert_eq!(ranges_hold(&ranges, cp), font.charmap().map(cp).is_some(), "U+{cp:04X}");
}
// Damaged data indexes as nothing.
assert!(coverage_ranges(b"ttcf\0\x01\0\0\xff\xff\xff\xff").is_empty());
assert!(coverage_ranges(&data[..100]).is_empty());
}
/// A bundled font renamed by a `name` table holding just its family names: (Windows language id,
/// name) pairs, English (0x0409) first, as SimSun's names 宋体 for Simplified Chinese.
fn with_family_names(file: &str, names: &[(u16, &str)]) -> Vec<u8> {