diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 48e416c2..96deafb4 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -331,22 +331,34 @@ mod web { }; if installed { log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}"); - } else { + } else if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) { log::warn!("CJK font unavailable; showing English for this session"); } self.font.finished(installed, &mut self.app.preferences.language); ctx.request_repaint(); } if self.font.tick(&mut self.app.preferences.language) { - let slot = self.font_result.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let res = fetch_font_bytes(JAPANESE_FONT_PATH).await; - *slot.borrow_mut() = Some(res); - ctx.request_repaint(); - }); + self.fetch_font(ctx); } } + + /// CJK text on screen with no CJK font yet (an English UI showing a Japanese name): + /// fetch the font, so the text does not stay tofu (#720). + fn watch_text_for_font(&mut self, ctx: &egui::Context) { + if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() { + self.fetch_font(ctx); + } + } + + fn fetch_font(&self, ctx: &egui::Context) { + let slot = self.font_result.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let res = fetch_font_bytes(JAPANESE_FONT_PATH).await; + *slot.borrow_mut() = Some(res); + ctx.request_repaint(); + }); + } } impl eframe::App for Shell { @@ -365,6 +377,7 @@ mod web { let ctx = ui.ctx().clone(); self.file_panel(&ctx); self.recovery_prompt(&ctx); + self.watch_text_for_font(&ctx); } fn save(&mut self, storage: &mut dyn eframe::Storage) { // Persist the user's own language, not a session-only English fallback. diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 796c995b..7eb78e5d 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -22,6 +22,38 @@ pub fn needs_cjk_font(language: &str) -> bool { CJK_LANGUAGES.contains(&language) } +/// Whether `text` has characters only the CJK fallback draws: kana, CJK ideographs and their +/// punctuation, and full-width forms. Hangul is left out: the web app's font has none. +pub fn has_cjk(text: &str) -> bool { + text.chars().any(|c| { + matches!(u32::from(c), + 0x3000..=0x30FF // CJK punctuation, Hiragana, Katakana + | 0x31F0..=0x31FF // Katakana phonetic extensions + | 0x3400..=0x4DBF // CJK Extension A + | 0x4E00..=0x9FFF // CJK Unified Ideographs + | 0xF900..=0xFAFF // CJK Compatibility Ideographs + | 0xFF00..=0xFFEF // Half-width and full-width forms + ) + }) +} + +/// Whether any text painted so far this frame has CJK characters (see [`has_cjk`]). The web app +/// calls it after drawing the UI, until its CJK font is loaded, so a Japanese design name, file +/// name or typed text fetches the font whatever the UI language (#720). +pub fn painted_cjk(ctx: &egui::Context) -> bool { + let mut layers: Vec = ctx.memory(|m| m.layer_ids().collect()); + layers.push(egui::LayerId::background()); + ctx.graphics(|g| layers.iter().filter_map(|l| g.get(*l)).any(|list| list.all_entries().any(|c| shape_has_cjk(&c.shape)))) +} + +fn shape_has_cjk(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(t) => has_cjk(&t.galley.job.text), + egui::Shape::Vec(shapes) => shapes.iter().any(shape_has_cjk), + _ => false, + } +} + /// `fonts` with `bytes` appended as the last fallback of the Proportional and Monospace /// families, after the Latin UI font. `None` when the bytes are not a font. pub fn with_cjk_fallback(fonts: egui::FontDefinitions, bytes: Vec) -> Option { @@ -128,6 +160,22 @@ impl CjkFontLoader { } } + /// CJK text is on screen (whatever the UI language). Returns true when the font fetch should + /// start now: once, and never again after a failed load (the UI language stays as it is). + pub fn text_needs_font(&mut self) -> bool { + if self.status == CjkFontStatus::Unloaded { + self.status = CjkFontStatus::Loading; + true + } else { + false + } + } + + /// Whether the session still watches painted text for CJK characters. + pub fn watching_text(&self) -> bool { + self.status == CjkFontStatus::Unloaded + } + /// Record the outcome of the fetch and install. pub fn finished(&mut self, installed: bool, language: &mut String) { if installed { @@ -423,6 +471,58 @@ mod tests { assert_eq!(language, "ja"); } + #[test] + fn cjk_text_is_recognised() { + for cjk in ["テスト", "ひらがな", "部品", "テスト", "Bracket(改)", "Plate 板"] { + assert!(has_cjk(cjk), "{cjk} has CJK characters"); + } + for other in ["", "Bracket", "Ø12 × 3 mm", "Größe", "Привет", "한국어", "émoji ✓"] { + assert!(!has_cjk(other), "{other} has no CJK characters the fallback draws"); + } + } + + /// An English UI fetches the font once CJK text is painted (#720), and a failed fetch leaves + /// the language alone. + #[test] + fn cjk_text_fetches_the_font_with_an_english_ui() { + let mut loader = CjkFontLoader::default(); + let mut language = "en".to_owned(); + assert!(!loader.tick(&mut language)); + assert!(loader.watching_text()); + assert!(loader.text_needs_font(), "CJK text starts the fetch"); + assert!(!loader.text_needs_font(), "the fetch starts once"); + assert!(!loader.tick(&mut language), "no second fetch from the language"); + assert!(!loader.watching_text()); + loader.finished(false, &mut language); + assert_eq!(loader.status(), CjkFontStatus::Failed); + assert_eq!(language, "en"); + assert!(!loader.text_needs_font(), "no retry loop after a failure"); + + let mut loader = CjkFontLoader::default(); + assert!(loader.text_needs_font()); + loader.finished(true, &mut language); + assert_eq!(loader.status(), CjkFontStatus::Installed); + // Switching to Japanese afterwards needs no second fetch. + let mut ja = "ja".to_owned(); + assert!(!loader.tick(&mut ja)); + } + + /// The painted-text check finds CJK in the frame's labels and nothing else. + #[test] + fn painted_cjk_sees_labels_in_the_frame() { + let ctx = egui::Context::default(); + let run = |text: &'static str| { + let mut seen = false; + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label(text); + seen = painted_cjk(ui.ctx()); + }); + seen + }; + assert!(!run("Untitled")); + assert!(run("テスト")); + } + #[test] fn failed_font_fetch_falls_back_for_the_session_without_changing_the_saved_language() { let mut loader = CjkFontLoader::default(); diff --git a/docs/localization.md b/docs/localization.md index c6ee236d..c81b2112 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -37,10 +37,12 @@ or craft-fonts. In the browser, `solidcraft-web` bundles a self-hosted Japanese font (`BIZUDPGothic-Regular.ttf`, licensed under SIL OFL 1.1) within the web distribution artifacts only. The font is loaded asynchronously from the same -origin (`./fonts/BIZUDPGothic-Regular.ttf`) on-demand when the interface language is set to Japanese, without -contacting any external CDNs or third-party servers. For users with an English interface, no font download -occurs. If font loading fails, the web interface shows English for that session only to prevent missing -glyphs (tofu); the saved language preference is kept, so the next visit tries again. This bundled font specifically targets Japanese; Korean (Hangul) and Chinese scripts are not covered. +origin (`./fonts/BIZUDPGothic-Regular.ttf`) on-demand when the interface language is set to Japanese, or, with +any other interface language, the first time Japanese text (kana, kanji or full-width forms in a design name, +file name, body name or typed text) is drawn, without contacting any external CDNs or third-party servers. +A session that never shows such text downloads no font. If font loading fails with the Japanese interface, +the web interface shows English for that session only to prevent missing glyphs (tofu); the saved language +preference is kept, so the next visit tries again. This bundled font specifically targets Japanese; Korean (Hangul) and Chinese scripts are not covered. The current messages support named substitution, not locale-specific plural selection, number/date formatting, or right-to-left layout and shaping. Add those capabilities when