ui: Render Chinese document names with font fallbacks

This commit is contained in:
jerrykimo 2026-10-08 20:50:09 -05:00
parent 5231852443
commit 34cf6005d0
4 changed files with 117 additions and 6 deletions

68
crates/ui-egui/src/cjk.rs Normal file
View File

@ -0,0 +1,68 @@
//! Chinese fallback fonts for document text in the interface, independent of UI language.
//! Optional craft-fonts are preferred; native builds without them reuse one installed face.
use std::sync::{Arc, OnceLock};
use egui::{FontData, FontDefinitions, FontFamily};
const SAMPLE: &str = "中文轨道配乐音效简体汉语繁體漢語车";
const PREFERRED: &[&str] = &[
"Microsoft YaHei UI",
"Microsoft YaHei",
"PingFang SC",
"Noto Sans CJK SC",
"Noto Sans SC",
"Source Han Sans SC",
"Source Han Sans CN",
"WenQuanYi Micro Hei",
"Microsoft JhengHei UI",
"Microsoft JhengHei",
];
pub(crate) fn craft_fonts() -> impl Iterator<Item = &'static filmcraft_text::fonts::CraftFont> {
filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| matches!(*s, "Hans" | "Hant")))
}
/// A font collection's face index matters: Windows Chinese UI fonts commonly live in TTC files.
/// Keep the owned bytes behind an Arc so theme reinstalls do not reread or leak a font file.
fn system_font() -> Option<Arc<FontData>> {
static FONT: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
FONT.get_or_init(|| {
if cfg!(target_arch = "wasm32") {
return None;
}
filmcraft_text::fonts::scan_system();
let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();
let covers = |f: &filmcraft_text::fonts::Face| SAMPLE.chars().all(|c| f.has_char(c));
let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);
let preferred = PREFERRED.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));
let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;
Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() }))
})
.clone()
}
/// Append Chinese faces after Latin UI fonts and before the Japanese fallbacks. This keeps the
/// interface's Latin typography and uses Chinese glyph forms for media and track names.
pub(crate) fn install(fonts: &mut FontDefinitions) {
let chinese: Vec<_> = craft_fonts().collect();
if chinese.is_empty() {
if let Some(font) = system_font() {
fonts.font_data.insert("system-chinese".into(), font);
for stack in fonts.families.values_mut() {
stack.push("system-chinese".into());
}
}
return;
}
let name = |f: &filmcraft_text::fonts::CraftFont| format!("craft:{} {}", f.family, f.style);
for f in &chinese {
fonts.font_data.insert(name(f), Arc::new(FontData::from_static(f.bytes)));
}
for (family, stack) in &mut fonts.families {
let heavy = matches!(family, FontFamily::Name(n) if matches!(n.as_ref(), "semibold" | "medium"));
let mut order = chinese.clone();
order.sort_by_key(|f| (f.family.contains("Serif") && !f.family.contains("Sans"), (f.style == "Bold") != heavy));
stack.extend(order.iter().map(|f| name(f)));
}
}

View File

@ -8,6 +8,7 @@
pub mod automation;
pub mod brand;
mod cjk;
pub mod control;
pub mod crash;
pub mod credits;

View File

@ -257,8 +257,8 @@ pub fn font_families() -> Vec<FontFamily> {
vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())]
}
/// Install fonts (Inter, Inter SemiBold, JetBrains Mono, then the Japanese craft-fonts when built with
/// `CRAFT_FONTS_DIR`) and egui visuals.
/// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese
/// craft-fonts and egui visuals. Document names keep their glyphs in every interface language.
pub fn install(ctx: &egui::Context, t: &Tokens) {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
@ -269,6 +269,7 @@ pub fn install(ctx: &egui::Context, t: &Tokens) {
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into());
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]);
fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]);
crate::cjk::install(&mut fonts);
add_craft_fonts(&mut fonts);
ctx.set_fonts(fonts);
apply_visuals(ctx, t);
@ -359,6 +360,39 @@ mod tests {
ctx
}
/// Chinese media and track names must render even when the interface stays in English.
/// Compare the rasterized glyphs with the missing-glyph box, including custom weight families.
#[test]
fn chinese_names_render_in_every_theme_and_font_family() {
const CHINESE: &str = "中文旁白音乐声轨测试简体汉语繁體漢語车";
filmcraft_text::fonts::scan_system();
let available = filmcraft_text::fonts::all_faces()
.iter()
.any(|f| matches!(f.info.origin, "system" | filmcraft_text::fonts::CRAFT_ORIGIN) && CHINESE.chars().all(|c| f.has_char(c)));
if !available {
eprintln!("SKIPPED: no craft-fonts or installed Chinese font");
return;
}
let ctx = egui::Context::default();
for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] {
install(&ctx, &Tokens::for_kind(kind));
let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
ctx.fonts_mut(|fonts| {
for family in font_families() {
let font = FontId::new(13.0, family);
let missing = fonts.layout_no_wrap("\u{fffd}".into(), font.clone(), Color32::WHITE);
let missing_uv = missing.rows[0].glyphs[0].uv_rect;
let names = fonts.layout_no_wrap(CHINESE.into(), font.clone(), Color32::WHITE);
assert_eq!(names.rows.iter().map(|r| r.glyphs.len()).sum::<usize>(), CHINESE.chars().count());
for glyph in names.rows.iter().flat_map(|r| &r.glyphs) {
assert!(!glyph.uv_rect.is_nothing(), "empty {} in {font:?}", glyph.chr);
assert_ne!(glyph.uv_rect, missing_uv, "missing {} in {kind:?} / {font:?}", glyph.chr);
}
}
});
}
}
/// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic
/// first), and Japanese text gets real glyphs, not the replacement box.
#[test]
@ -373,7 +407,8 @@ mod tests {
for (family, stack) in &defs.families {
let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len());
assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
assert!(stack[first].starts_with("craft:BIZ UDPGothic"), "{family:?}: {stack:?}");
let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori"));
assert!(first_japanese.is_some_and(|n| n.starts_with("craft:BIZ UDPGothic")), "{family:?}: {stack:?}");
}
for family in [FontFamily::Proportional, FontFamily::Monospace] {
let font = FontId::new(13.0, family);
@ -389,12 +424,11 @@ mod tests {
assert!(galley.size().x > 13.0 * 4.0, "{:?}", galley.size());
}
/// Built without craft-fonts: the theme installs only the app's own fonts and Latin text
/// works as before.
/// Latin text keeps its UI faces, with or without craft-fonts and system Chinese fonts.
#[test]
fn works_without_craft_fonts() {
let ctx = installed();
let n = filmcraft_text::fonts::craft_japanese().count();
let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count();
ctx.fonts_mut(|fonts| {
let defs = fonts.definitions().clone();
assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n);

View File

@ -66,6 +66,14 @@ FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a c
warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts'
glyphs skip when it is empty; when you touch fonts, run the gates both with and without it.
Chinese media names and track labels use the `Hans` / `Hant` craft-fonts faces before the Japanese
fallbacks in every UI font family, regardless of interface language. Without those build inputs,
native builds look up an installed Chinese face (for example Microsoft YaHei or Noto Sans CJK SC)
once and reuse it across theme changes. No system font files are bundled or copied into the app.
Web builds depend on the font faces selected by `crates/text/build.rs`; they cannot scan system fonts.
`theme::tests::chinese_names_render_in_every_theme_and_font_family` verifies Chinese glyphs in the
timeline's proportional, monospace, medium and semibold families under all three themes.
## 3. Quality gates
Every commit must pass all of these: