Merge #394: CJK interface text from installed fonts, on the Latin baseline

Conflict: crates/ui-egui/src/theme.rs install_fonts: the PR's
system_ui_fonts/add_system_fallbacks replace next's #126 system-font loop
(fallback_for per CJK sample char into system-ui-{id} faces when
CRAFT_FONTS is empty), so installed faces are not loaded twice. No #126
helper in theme.rs is left unused; FontDb::fallback_for stays (compose,
images use it).
This commit is contained in:
aiongg
2026-10-10 23:29:09 +00:00
3 changed files with 234 additions and 32 deletions
+10
View File
@@ -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<Feature> {
let (on, t) = match tag.strip_prefix('-') {
Some(r) => (false, r),
+3 -1
View File
@@ -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();
+221 -31
View File
@@ -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<u8>,
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<SystemFont> {
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<String> = system.iter().filter(|f| f.script == first).map(name).collect();
let rest: Vec<String> = ["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(&[]);