diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5b21cb4..02e3be1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -294,6 +294,7 @@ pub struct FontDb { /// search sorts every loaded face, so without this text in a script the requested font lacks /// (Thai, #59) got slower with every font loaded. fallbacks: RwLock, + ea_fallbacks: RwLock, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, /// Fonts the user added at run time, and the folder they are stored in (#582). @@ -506,6 +507,33 @@ struct FallbackCache { map: HashMap<(char, u32), Option>>, } +/// [`FontDb::fallback_for_east_asian`] results: (sorted, deduplicated chars, excluded face, bold, +/// italic) → face, valid while the loaded face count is `faces`. +#[derive(Default)] +struct EaFallbackCache { + faces: usize, + /// Chars held in the keys (bounds memory: hostile runs can be long). + chars: usize, + #[allow(clippy::type_complexity)] + map: HashMap<(Vec, u32, bool, bool), Option>>, +} + +/// Most cached run-level East Asian fallbacks, and most chars in their keys, before the cache is +/// reset. +const EA_FALLBACK_CACHE_MAX: usize = 4096; +const EA_FALLBACK_CACHE_CHARS: usize = 1 << 20; + +/// Loaded faces other than `exclude`, in fallback order: for right-to-left text the fonts made +/// for the script first (so a word isn't split across fonts, which breaks Arabic joining), then +/// the fallback family, upright, nearest to regular weight. +fn fallback_order(faces: &[Arc], exclude: u32, rtl: bool) -> Vec<&Arc> { + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + let rank = + |f: &FontFace| if rtl { RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) } else { 0 }; + order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order +} + const FALLBACK_CACHE_MAX: usize = 20_000; static NEXT_ID: AtomicU32 = AtomicU32::new(1); @@ -844,6 +872,7 @@ impl FontDb { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), fallbacks: RwLock::new(FallbackCache::default()), + ea_fallbacks: RwLock::new(EaFallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), user: RwLock::new(user::UserFonts::default()), @@ -873,6 +902,7 @@ impl FontDb { } // Misses remembered while it was off may be found now. self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear(); + *self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = EaFallbackCache::default(); #[cfg(target_arch = "wasm32")] let _ = on; } @@ -1358,6 +1388,88 @@ impl FontDb { (order.into_iter().find(|f| f.covers(c)).cloned(), faces.len()) } + /// One fallback face for a run's East Asian characters: the first face in the fallback order + /// that covers **every** given character, so a whole run draws in a single CJK face instead of + /// mixing faces character by character (a bundled Japanese Mincho for JIS-covered ideographs + /// beside a Simplified-Chinese sans for the rest). Like [`Self::fallback_for`], a system font + /// is loaded lazily the first time no loaded face covers a character. `None` when no single + /// face covers them all, and the caller keeps the per-character fallback. + /// + /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces + /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search. + pub fn fallback_for_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option> { + if chars.is_empty() { + return None; + } + let mut set: Vec = chars.to_vec(); + set.sort_unstable(); + set.dedup(); + let key = (set, exclude, bold, italic); + let n = self.read_faces().len(); + { + let cache = self.ea_fallbacks.read().unwrap_or_else(|e| e.into_inner()); + if cache.faces == n + && let Some(hit) = cache.map.get(&key) + { + return hit.clone(); + } + } + let found = self.search_east_asian(&key.0, exclude, bold, italic); + // Keyed by the face count after the search (a system fallback may have loaded fonts). + let n = self.read_faces().len(); + let mut cache = self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()); + if cache.faces != n || cache.map.len() >= EA_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > EA_FALLBACK_CACHE_CHARS { + cache.map.clear(); + cache.faces = n; + cache.chars = 0; + } + cache.chars = cache.chars.saturating_add(key.0.len()); + cache.map.insert(key, found.clone()); + found + } + + fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option> { + let mut base = self.loaded_covering(chars, exclude); + #[cfg(not(target_arch = "wasm32"))] + if base.is_none() { + // A character no loaded face covers: load a system font for it, then retry (the run's + // other characters may already be covered by loaded faces). + for c in chars { + if self.read_faces().iter().any(|f| f.id != exclude && f.covers(*c)) { + continue; + } + if self.system_fallback(*c) + && let Some(f) = self.loaded_covering(chars, exclude) + { + base = Some(f); + break; + } + } + } + let base = base?; + if !bold && !italic { + return Some(base); + } + let style = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + _ => "Italic", + }; + let styled = self.face(&base.family, style); + Some(if styled.family.eq_ignore_ascii_case(&base.family) && chars.iter().all(|c| styled.covers(*c)) && styled.id != exclude { + styled + } else { + base + }) + } + + /// The first face in the fallback order that covers every char. + fn loaded_covering(&self, chars: &[char], exclude: u32) -> Option> { + let faces = self.read_faces(); + let rtl = chars.iter().any(|c| crate::is_rtl(*c) || arabic_block(*c)); + fallback_order(&faces, exclude, rtl).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned() + } + /// Load a system font covering `c` (preferred fallback families first, then any cataloged /// file under 40 MB, found through the coverage index). Returns true if one was loaded. /// Misses are remembered. @@ -1501,6 +1613,33 @@ mod tests_sysfonts; mod tests { use super::*; + #[test] + fn east_asian_run_fallback_is_cached_per_char_set() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(FALLBACK_FAMILY, "Regular"); + let a = db.fallback_for_east_asian(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id()); + // The same set in another order (and without the repeat) is the same entry. + assert_eq!(db.fallback_for_east_asian(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a); + { + let cache = db.ea_fallbacks.read().unwrap(); + assert_eq!(cache.faces, db.read_faces().len()); + assert_eq!(cache.map.len(), 1); + assert!(cache.map.contains_key(&(vec!['分', '类'], latin.id(), false, false))); + } + // Long hostile runs keep the cache bounded. + let many: Vec = (0x4E00u32..0x4E00 + 50_000).filter_map(char::from_u32).collect(); + for i in 0..30 { + let _ = db.fallback_for_east_asian(many.get(i..).unwrap_or(&[]), latin.id(), false, false); + } + let cache = db.ea_fallbacks.read().unwrap(); + assert!(cache.chars <= EA_FALLBACK_CACHE_CHARS, "{}", cache.chars); + // Turning the system fallback on or off forgets remembered misses. + drop(cache); + db.set_system_fallback(false); + assert!(db.ea_fallbacks.read().unwrap().map.is_empty()); + } + #[test] fn legacy_family_keeps_the_style_words_that_are_not_regular_bold_or_italic() { for (family, style, legacy) in [ diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 15c9c2c..0d04ac7 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -360,6 +360,30 @@ mod tests { assert!(fb.family.contains("Mincho"), "{fb:?}"); } + #[test] + fn east_asian_run_uses_one_face_covering_the_whole_run() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(DEFAULT_FAMILY, "Regular"); + if CRAFT_FONTS.is_empty() { + // No bundled CJK font and no system fallback: nothing can draw the run. + assert!(db.fallback_for_east_asian(&['分', '类'], latin.id(), false, false).is_none()); + return; + } + // JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the + // SC sans: the run-level pick must cover every character in one face, or a run mixes + // serif and sans glyphs (the per-character fallback still applies when it returns None). + let run: Vec = "分类监督学习".chars().collect(); + let f = db.fallback_for_east_asian(&run, latin.id(), false, false).expect("a CJK face covers the run"); + for c in &run { + assert!(f.covers(*c), "{f:?} covers {c}"); + } + // Pure Japanese still gets the document serif (Mincho first). + let jp: Vec = "日本語".chars().collect(); + let f2 = db.fallback_for_east_asian(&jp, latin.id(), false, false).expect("a CJK face"); + assert!(f2.family.contains("Mincho"), "{f2:?}"); + } + #[test] fn works_without_craft_fonts() { // Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load. diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 4ab1daf..85e8e0c 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -595,6 +595,22 @@ impl<'a> Builder<'a> { .filter(|r| !r.substituted) .map(|r| r.face); let use_levels = para_text && !self.levels.is_empty(); + // One fallback face for the run's East Asian characters: the first face in fallback order + // covering every character the primary font lacks, so the run draws in a single CJK face + // instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered + // ideographs beside a Simplified-Chinese sans for the rest). Whitespace and characters the + // primary covers don't constrain the choice. `None` keeps the per-character fallback. + let ea_face: Option = { + let ea: Vec = text + .chars() + .filter(|c| crate::kinsoku::is_east_asian(*c) && !primary.covers(*c) && !c.is_whitespace() && !c.is_control() && *c != SOFT_HYPHEN) + .collect(); + if ea.is_empty() { + None + } else { + wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f)) + } + }; // Split by font coverage (fallback faces), small caps case, script formatting and direction. let mut seg_start = 0; let mut cur: Option = None; @@ -612,6 +628,8 @@ impl<'a> Builder<'a> { _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, // Stay in the fallback face the word started in while it covers the letters. Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), + // The run's unified East Asian face, when one face covers the whole run. + _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face, _ => { let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) }; wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f))