mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(&[]);
|
||||
|
||||
Reference in New Issue
Block a user