ui: Render Chinese document names with font fallbacks
This commit is contained in:
parent
5231852443
commit
34cf6005d0
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
pub mod automation;
|
||||
pub mod brand;
|
||||
mod cjk;
|
||||
pub mod control;
|
||||
pub mod crash;
|
||||
pub mod credits;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue