diff --git a/ROADMAP.md b/ROADMAP.md index f31b96d..50573aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 00dbe20..0de5de5 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -292,6 +292,13 @@ pub struct FontDb { /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, + /// 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, + /// 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, + /// 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, + /// The cataloged font files, sorted. + files: Vec, + /// 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>, + /// 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> { + 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 = 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::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect(); + let mut paths: Vec = 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 } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 7826504..7cdbb23 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -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 = 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 {