diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index d3cbb618d..287e6e492 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -81,6 +81,16 @@ pub struct ShapedGlyph { } /// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +/// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent +/// (negative below the baseline), line gap) — what text layouts such as egui's build lines from. +/// `None` if the font can't be read. +pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { + use skrifa::MetadataProvider; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) +} + pub fn feature(tag: &str) -> Option { let (on, t) = match tag.strip_prefix('-') { Some(r) => (false, r), diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..79f130043 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -300,7 +300,9 @@ mod tests { fn with_fonts(test: impl FnOnce(&egui::Context)) { let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx, "ar"); + // The built-in faces only: installed fonts (the CJK fallbacks) would make the glyphs + // measured here depend on the machine. + ctx.set_fonts(crate::theme::font_definitions(designcraft_fonts::CRAFT_FONTS, "ar")); ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); test(&ctx); let mut output = ctx.end_pass(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index cf503875b..911b74188 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -255,34 +255,125 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). pub fn install_fonts(ctx: &egui::Context, lang: &str) { - let fonts = font_definitions(designcraft_fonts::CRAFT_FONTS, lang); - #[cfg(not(target_arch = "wasm32"))] - let fonts = { - let mut fonts = fonts; - // Local builds may omit craft-fonts. Use the shared resolver for readable document - // names and font menus without bundling or copying installed fonts into the project. - if designcraft_fonts::CRAFT_FONTS.is_empty() { - let db = designcraft_fonts::FontDb::global(); - for c in ['中', 'あ', '한'] { - if let Some(face) = db.fallback_for(c, u32::MAX, Some(lang)) { - let name = format!("system-ui-{}", face.id()); - if fonts.font_data.contains_key(&name) { - continue; - } - let mut data = FontData::from_owned(face.data().to_vec()); - data.index = face.index(); - fonts.font_data.insert(name.clone(), Arc::new(data)); - for (family, stack) in &mut fonts.families { - if !matches!(family, FontFamily::Name(name) if name.starts_with("arabic")) { - stack.push(name.clone()); - } - } - } - } - } - fonts + let craft = designcraft_fonts::CRAFT_FONTS; + let mut defs = font_definitions(craft, lang); + add_system_fallbacks(&mut defs, &system_ui_fonts(craft), lang); + ctx.set_fonts(defs); +} + +/// 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". + script: &'static str, + family: String, + bytes: Vec, + index: u32, +} + +/// 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])] = &[ + ( + "Jpan", + &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Yu Gothic UI", + "Yu Gothic", + "Meiryo", + "MS Gothic", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans JP", + "IPAexGothic", + "Droid Sans Fallback", + ], + ), + ("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"]), +]; + +/// 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. +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Vec { + let db = designcraft_fonts::FontDb::global(); + 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 { + script, + family: (*family).to_string(), + bytes: face.data().to_vec(), + index: face.index(), + }) + }) + .collect() +} + +/// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). +const UI_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// The tweak that puts a fallback face's baseline on the UI face's. egui centres a fallback's +/// line height in the primary's instead (`pos.y = ascent + (line height − its line height) / 2`), +/// so a CJK face with a taller ascent or a larger line gap would sit above or below the Latin +/// text beside it. Shifts are in ems of the font size. +pub(crate) fn baseline_tweak(face: &[u8], index: u32) -> egui::FontTweak { + let metrics = |data: &[u8], i: u32| { + designcraft_fonts::line_metrics(data, i).map(|(upem, ascent, descent, gap)| (ascent / upem, (ascent - descent + gap) / upem)) }; - ctx.set_fonts(fonts); + let shift = match (metrics(UI_FONT, 0), metrics(face, index)) { + (Some((ap, hp)), Some((af, hf))) => ap - (af + 0.5 * (hp - hf)), + _ => 0.0, + }; + egui::FontTweak { y_offset_factor: if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }, ..Default::default() } +} + +/// The script whose letterforms the interface language `lang` prefers for Han characters, Kana +/// and Hangul: "Jpan", "Hans", "Hant" or "Kore". Languages without a CJK preference use Japanese +/// forms, the app's default CJK face. +fn cjk_script(lang: &str) -> &'static str { + match lang { + "zh" | "zh-hans" | "zh-cn" => "Hans", + "zh-hant" | "zh-tw" | "zh-hk" => "Hant", + "ko" => "Kore", + _ => "Jpan", + } +} + +/// 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. +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 { + fonts.font_data.insert( + name(f), + Arc::new(FontData { index: f.index, tweak: baseline_tweak(&f.bytes, f.index), ..FontData::from_owned(f.bytes.clone()) }), + ); + } + 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"] + .iter() + .filter(|s| **s != first) + .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .map(name) + .collect(); + for (family, stack) in fonts.families.iter_mut() { + // The Arabic families keep their own order (see `font_definitions`). + let arabic = matches!(family, FontFamily::Name(n) if n.starts_with("arabic")); + let at = if arabic { stack.len() } else { stack.iter().position(|n| n.starts_with("craft-")).unwrap_or(stack.len()) }; + stack.splice(at..at, preferred.iter().cloned()); + stack.extend(rest.iter().cloned()); + } } /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without @@ -290,7 +381,7 @@ pub fn install_fonts(ctx: &egui::Context, lang: &str) { /// Simplified Chinese, in the interface language's order, then Arabic. The `arabic` families /// put the Arabic face first for right-to-left runs. egui has no system-font discovery, so /// without craft-fonts CJK and Arabic UI text has no glyphs. -fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { +pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(data))); @@ -308,11 +399,15 @@ fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) let chinese = pick("Hans"); let arabic = pick("Arab"); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); - for f in japanese.iter().chain(&chinese).chain(&arabic) { + for f in japanese.iter().chain(&chinese) { + // CJK faces sit on the Latin baseline (see `baseline_tweak`). + fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); + } + for f in &arabic { add(&mut fonts, &name(f), f.bytes); } - // Han characters take the forms of the interface language: Chinese faces first for zh. - let zh = lang.starts_with("zh"); + // Han characters take the forms of the interface language: Chinese faces first for Chinese. + let zh = matches!(cjk_script(lang), "Hans" | "Hant"); for (family, stack) in fonts.families.iter_mut() { let bold = *family == FontFamily::Name("semibold".into()); let mut ja = japanese.clone(); @@ -433,6 +528,101 @@ mod japanese_font_tests { assert_eq!(stack.iter().find(|n| n.starts_with("craft-")).map(String::as_str), Some("craft-BIZ UDPGothic-Regular")); } + /// Without craft-fonts, an installed Japanese face gives the interface its glyphs (here a + /// synthetic stand-in, so the test doesn't depend on the machine's fonts). + #[test] + fn installed_fonts_give_japanese_glyphs_without_craft_fonts() { + let bytes = designcraft_fonts::testing::font_with("DC UI Japanese", &['日', '本', '語']).unwrap(); + let system = [super::SystemFont { script: "Jpan", family: "DC UI Japanese".into(), bytes, index: 0 }]; + let mut defs = super::font_definitions(&[], "ja"); + super::add_system_fallbacks(&mut defs, &system, "ja"); + for family in families() { + assert_eq!(defs.families[&family].last().map(String::as_str), Some("system-DC UI Japanese"), "{family:?}"); + } + 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 "日本語".chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + + /// A fallback face with other line metrics lands on the UI face's baseline: egui puts a glyph + /// at `ascent + (primary line height − its line height) / 2`, plus the tweak. + #[test] + fn fallback_faces_share_the_ui_baseline() { + let ui = designcraft_fonts::line_metrics(super::UI_FONT, 0).unwrap(); + // A synthetic face with a taller ascent and a line gap (Hiragino-like proportions). + let base = designcraft_fonts::testing::font_with("DC Tall", &['日']).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(base, b"hhea", 4, 1200u16).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(tall, b"hhea", 8, 500u16).unwrap(); + let face = designcraft_fonts::line_metrics(&tall, 0).unwrap(); + let tweak = super::baseline_tweak(&tall, 0); + let em = |m: (f32, f32, f32, f32)| (m.1 / m.0, (m.1 - m.2 + m.3) / m.0); + let ((ap, hp), (af, hf)) = (em(ui), em(face)); + let baseline = af + 0.5 * (hp - hf) + tweak.y_offset_factor; + assert!((baseline - ap).abs() < 1e-4, "{baseline} vs {ap}"); + assert!(tweak.y_offset_factor.abs() > 0.01, "the synthetic face needs a shift: {}", tweak.y_offset_factor); + // The UI face itself needs no shift. + assert!(super::baseline_tweak(super::UI_FONT, 0).y_offset_factor.abs() < 1e-6); + } + + /// Korean font names (the font menus show Korean families by their Hangul names) get glyphs + /// from an installed Korean face, after the Japanese and Chinese ones. + #[test] + fn installed_korean_faces_give_hangul_glyphs() { + let face = |family: &str, chars: &[char], script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, chars).unwrap(), + index: 0, + }; + let system = [face("DC UI Korean", &['한', '글'], "Kore"), face("DC UI Japanese", &['日'], "Jpan")]; + let mut defs = super::font_definitions(&[], "ja"); + 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-DC UI Japanese") < at("system-DC UI Korean")); + 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); + assert!(fonts.has_glyph(&font, '한') && fonts.has_glyph(&font, '글')); + }); + } + + /// Han characters take the interface language's forms: its script's installed face comes + /// before the built-in CJK faces and the other scripts' faces. + #[test] + fn interface_language_orders_the_cjk_faces() { + let face = |family: &str, script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, &['日']).unwrap(), + index: 0, + }; + let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Hant", "Hant"), face("DC Ko", "Kore")]; + for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { + let mut defs = super::font_definitions(&[], lang); + // Stand-in for a built-in CJK face (craft-fonts may be absent in this build). + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, lang); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + let first_at = at(&format!("system-{first}")); + assert!(first_at < at("craft-Stand-in"), "{lang}: {stack:?}"); + for f in &system { + assert!(first_at <= at(&format!("system-{}", f.family)), "{lang}: {stack:?}"); + } + } + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]);