The browser build fetched its Japanese fallback font only for a Japanese UI, so a Japanese design name, file name or typed text showed as tofu with the UI in English. After drawing each frame the web shell now looks through the painted text for kana, CJK ideographs and full-width forms while no CJK font is loaded, and fetches the same font once on the first hit. A failed fetch with an English UI leaves the language alone and is not retried in a loop. Sessions that never show such text download nothing extra. Fixes #720 Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5808bcc0f0
commit
8746376ee9
3 files changed
+127
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<egui::LayerId> = 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<u8>) -> Option<egui::FontDefinitions> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user