diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 055ba7282..45a0afafa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -259,7 +259,7 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). /// Returns false while the installed fonts are still being cataloged: the built-in faces are /// installed without waiting, and the caller installs again once they are (see -/// [`system_fonts_ready`]) to add the installed CJK faces. +/// [`system_fonts_ready`]) to add the installed faces for the scripts they lack. pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); @@ -278,7 +278,7 @@ pub fn system_fonts_ready() -> bool { /// An installed font the UI falls back to for a script the built-in faces lack. pub(crate) struct SystemFont { - /// "Jpan", "Hans", "Hant" or "Kore". + /// The ISO 15924 code of the script it is for ("Jpan", "Kore", "Taml"…). script: &'static str, family: String, /// The face as egui takes it, on the UI face's baseline. Shared: installing the fonts again @@ -293,11 +293,14 @@ impl SystemFont { } } -/// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then -/// Windows, then Linux families. -const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ +/// Installed UI faces (sans serif) per script, with a character every face of the script has: +/// the first listed family that is installed and covers the character, macOS families first, +/// then Windows, then Linux. One family can serve several scripts (Nirmala UI covers the Indic +/// scripts); it is added once. +const SYSTEM_UI_FONTS: &[(&str, char, &[&str])] = &[ ( "Jpan", + 'あ', &[ "Hiragino Sans", "Hiragino Kaku Gothic ProN", @@ -312,16 +315,36 @@ const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ "Droid Sans Fallback", ], ), - ("Hans", &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), + ("Hans", '中', &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), // Traditional Chinese and Korean: font menus list those families under their native names. - ("Hant", &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), - ("Kore", &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + ("Hant", '中', &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), + ("Kore", '한', &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + // Scripts the built-in faces lack, after the CJK ones (text in the Story Editor, font names). + ("Taml", 'த', &["Tamil Sangam MN", "Tamil MN", "Nirmala UI", "Latha", "Noto Sans Tamil", "Noto Sans Tamil UI", "Lohit Tamil"]), + ("Deva", 'क', &["Kohinoor Devanagari", "Devanagari Sangam MN", "Nirmala UI", "Mangal", "Noto Sans Devanagari", "Lohit Devanagari"]), + ("Beng", 'ক', &["Kohinoor Bangla", "Bangla Sangam MN", "Nirmala UI", "Vrinda", "Noto Sans Bengali", "Lohit Bengali"]), + ("Gujr", 'ક', &["Kohinoor Gujarati", "Gujarati Sangam MN", "Nirmala UI", "Shruti", "Noto Sans Gujarati", "Lohit Gujarati"]), + ("Guru", 'ਕ', &["Gurmukhi Sangam MN", "Gurmukhi MN", "Nirmala UI", "Raavi", "Noto Sans Gurmukhi", "Lohit Gurmukhi"]), + ("Knda", 'ಕ', &["Kannada Sangam MN", "Nirmala UI", "Tunga", "Noto Sans Kannada", "Lohit Kannada"]), + ("Mlym", 'ക', &["Malayalam Sangam MN", "Nirmala UI", "Kartika", "Noto Sans Malayalam", "Lohit Malayalam"]), + ("Telu", 'క', &["Kohinoor Telugu", "Telugu Sangam MN", "Nirmala UI", "Gautami", "Noto Sans Telugu", "Lohit Telugu"]), + ("Orya", 'କ', &["Oriya Sangam MN", "Nirmala UI", "Kalinga", "Noto Sans Oriya", "Lohit Odia"]), + ("Sinh", 'ක', &["Sinhala Sangam MN", "Nirmala UI", "Iskoola Pota", "Noto Sans Sinhala", "LKLUG"]), + ("Thai", 'ก', &["Thonburi", "Leelawadee UI", "Tahoma", "Noto Sans Thai", "Loma"]), + ("Khmr", 'ក', &["Khmer Sangam MN", "Leelawadee UI", "Khmer UI", "Noto Sans Khmer", "Khmer OS"]), + ("Laoo", 'ກ', &["Lao Sangam MN", "Leelawadee UI", "Lao UI", "Noto Sans Lao", "Phetsarath OT"]), + ("Mymr", 'က', &["Myanmar Sangam MN", "Myanmar Text", "Noto Sans Myanmar", "Padauk"]), + ("Hebr", 'א', &["Arial Hebrew", "Segoe UI", "Noto Sans Hebrew", "DejaVu Sans"]), + ("Armn", 'Ա', &["Mshtakan", "Segoe UI", "Sylfaen", "Noto Sans Armenian", "DejaVu Sans"]), + ("Geor", 'ა', &["Segoe UI", "Sylfaen", "Noto Sans Georgian", "DejaVu Sans"]), + ("Ethi", 'አ', &["Kefa", "Ebrima", "Nyala", "Noto Sans Ethiopic", "Abyssinica SIL"]), ]; -/// The installed faces for the CJK scripts `craft` (the built-in craft-fonts) doesn't cover, so -/// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and -/// Traditional Chinese (font names in the font menus) in any build. egui can't -/// find system fonts itself; the document font scan (not on the web) does. +/// The installed faces for the scripts `craft` (the built-in craft-fonts) doesn't cover, so the +/// interface shows Japanese and Chinese without the craft-fonts build input, and Korean, +/// Traditional Chinese (font names in the font menus), Indic, Southeast Asian and other scripts +/// in any build. egui can't find system fonts itself; the document font scan (not on the web) +/// does. fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { static FACES: std::sync::OnceLock> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); @@ -332,19 +355,30 @@ fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [S db.scan_in_background(); return None; } - Some(FACES.get_or_init(|| { - SYSTEM_UI_FONTS - .iter() - .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) - .filter_map(|(script, families)| { - let family = families.iter().find(|f| db.has_family(f))?; - let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()) - .then(|| SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())) - }) - .collect() - })) + Some(FACES.get_or_init(|| installed_ui_fonts(db, craft))) +} + +/// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover. +fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont]) -> Vec { + let mut faces: Vec = Vec::new(); + for (script, sample, families) in SYSTEM_UI_FONTS { + if craft.iter().any(|f| f.scripts.contains(script)) { + continue; + } + let found = families.iter().find_map(|family| { + if !db.has_family(family) { + return None; + } + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample)).then_some((family, face)) + }); + let Some((family, face)) = found else { continue }; + if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)) { + faces.push(SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())); + } + } + faces } /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). @@ -379,7 +413,8 @@ fn cjk_script(lang: &str) -> &'static str { /// Add installed faces `system` to every family in the interface language's order: the faces of /// its script (`cjk_script`) go before the built-in CJK faces, so Han characters take that -/// language's forms (Traditional Chinese, Korean); the others go at the end. +/// language's forms (Traditional Chinese, Korean); the other CJK faces go at the end, then the +/// other scripts' faces. pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { let name = |f: &SystemFont| format!("system-{}", f.family); for f in system { @@ -387,10 +422,12 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System } let first = cjk_script(lang); let preferred: Vec = system.iter().filter(|f| f.script == first).map(name).collect(); - let rest: Vec = ["Jpan", "Hans", "Hant", "Kore"] + const CJK: [&str; 4] = ["Jpan", "Hans", "Hant", "Kore"]; + let rest: Vec = CJK .iter() .filter(|s| **s != first) .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .chain(system.iter().filter(|f| !CJK.contains(&f.script))) .map(name) .collect(); for (family, stack) in fonts.families.iter_mut() { @@ -638,6 +675,50 @@ mod japanese_font_tests { } } + /// Tamil, Devanagari and Thai text get glyphs from installed faces, found by family and + /// checked for coverage, after the CJK faces. Synthetic fonts in a folder of their own stand + /// in for the machine's. + #[test] + fn installed_faces_give_other_scripts_glyphs_after_the_cjk_faces() { + let dir = std::env::temp_dir().join(format!("dc-ui-script-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fonts = [ + ("Noto Sans CJK JP", &['あ', '日'][..]), + // Listed first for Tamil but without Tamil glyphs: passed over. + ("Tamil Sangam MN", &['a'][..]), + // One face for two scripts is added once. + ("Nirmala UI", &['த', 'க', 'क'][..]), + ("Noto Sans Thai", &['ก'][..]), + ]; + for (i, (family, chars)) in fonts.iter().enumerate() { + std::fs::write(dir.join(format!("font{i}.ttf")), designcraft_fonts::testing::font_with(family, chars).unwrap()).unwrap(); + } + let db = designcraft_fonts::FontDb::with_font_dirs(vec![dir.clone()]); + let system = super::installed_ui_fonts(&db, &[]); + let _ = std::fs::remove_dir_all(&dir); + let picked: Vec<(&str, &str)> = system.iter().map(|f| (f.script, f.family.as_str())).collect(); + assert_eq!(picked, [("Jpan", "Noto Sans CJK JP"), ("Taml", "Nirmala UI"), ("Thai", "Noto Sans Thai")]); + + let mut defs = super::font_definitions(&[], "ja"); + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, "ja"); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + assert!(at("system-Noto Sans CJK JP") < at("craft-Stand-in"), "{stack:?}"); + assert!(at("craft-Stand-in") < at("system-Nirmala UI") && at("system-Nirmala UI") < at("system-Noto Sans Thai"), "{stack:?}"); + defs.families.entry(egui::FontFamily::Proportional).or_default().retain(|n| n != "craft-Stand-in"); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + for ch in ['த', 'क', 'ก'] { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]);