Fix CJK fallback mixing serif/sans faces within one run (#567)

* Fix CJK fallback mixing serif/sans faces within one run

Per-character fallback picked the first covering face in load order, so a
run without an East Asian font could draw JIS-covered ideographs in a
bundled Japanese Mincho and simplified-only ones in the SC sans, mixing
serif and sans glyphs even inside one word. Pick one face covering all
of the run's East Asian characters when available, keeping the
per-character fallback when none does.

* Review fixes for #567: cache the run-level CJK fallback

- fallback_for_east_asian is cached per (character set, excluded face,
  bold, italic) and dropped when the loaded faces change or the system
  fallback is switched, like the per-character cache, so relayout doesn't
  redo the search; the cache is bounded in entries and in key size.
- loaded_fallback and loaded_covering share one fallback_order helper.
- Test for the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jeleesky 2026-10-12 16:24:03 +08:00 committed by GitHub
parent d3e2e184c2
commit f4dfc8eb85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 181 additions and 0 deletions

View File

@ -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<FallbackCache>,
ea_fallbacks: RwLock<EaFallbackCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// 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<Arc<FontFace>>>,
}
/// [`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<char>, u32, bool, bool), Option<Arc<FontFace>>>,
}
/// 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<FontFace>], exclude: u32, rtl: bool) -> Vec<&Arc<FontFace>> {
let mut order: Vec<&Arc<FontFace>> = 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<Arc<FontFace>> {
if chars.is_empty() {
return None;
}
let mut set: Vec<char> = 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<Arc<FontFace>> {
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<Arc<FontFace>> {
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<char> = (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 [

View File

@ -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<char> = "分类监督学习".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<char> = "日本語".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.

View File

@ -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<FaceRef> = {
let ea: Vec<char> = 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<SegKey> = 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))