From 34cf6005d041c9fa49e4e0422ac71ea7c16d498e Mon Sep 17 00:00:00 2001 From: jerrykimo <104250063+jerrykimo@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:50:09 -0500 Subject: [PATCH] ui: Render Chinese document names with font fallbacks --- crates/ui-egui/src/cjk.rs | 68 +++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 46 +++++++++++++++++++++---- docs/contributing.md | 8 +++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/cjk.rs diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs new file mode 100644 index 00000000..a92ccc36 --- /dev/null +++ b/crates/ui-egui/src/cjk.rs @@ -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 { + 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> { + static FONT: OnceLock>> = 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| 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))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 461aa36e..039ab8c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod automation; pub mod brand; +mod cjk; pub mod control; pub mod crash; pub mod credits; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4de49842..fdce69c8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -257,8 +257,8 @@ pub fn font_families() -> Vec { 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::(), 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); diff --git a/docs/contributing.md b/docs/contributing.md index 5bfaf1cf..63b143b1 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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: