//! System font loading and fallback setup for multi-language (CJK: Japanese, Chinese, Korean) support. //! //! The UI's Latin font always comes first; a CJK font (then a Hangul font) is only ever appended as //! a fallback, so English text renders exactly as before and only glyphs the Latin font lacks use //! the CJK face. The Hangul fallback covers Korean where the CJK font found (Meiryo, Hiragino) //! lacks Hangul. The order of the CJK and Chinese faces follows the UI language //! (see [`fallback_order`]): one face drawing a whole word keeps its glyphs on one baseline. use std::sync::Arc; /// Name of the CJK fallback entry in [`egui::FontDefinitions`]. pub const CJK_FALLBACK: &str = "cjk_fallback"; /// Name of the Chinese fallback entry in [`egui::FontDefinitions`], after [`CJK_FALLBACK`]. /// A Japanese-only CJK font (Meiryo, Hiragino Gothic) lacks simplified characters, so /// Chinese falls through to this one per glyph. pub const CHINESE_FALLBACK: &str = "chinese_fallback"; /// Name of the Hangul fallback entry in [`egui::FontDefinitions`], last. pub const HANGUL_FALLBACK: &str = "hangul_fallback"; /// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only; /// add Chinese or Korean here together with a font that covers them. pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "ko"]; /// Whether the UI language needs a CJK font (the web app fetches one only then). 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 { with_fallback(fonts, CJK_FALLBACK, bytes) } /// `fonts` with `bytes` appended under `name` as the last fallback of the Proportional and /// Monospace families. `None` when the bytes are not a font. fn with_fallback(mut fonts: egui::FontDefinitions, name: &str, bytes: Vec) -> Option { ttf_parser::Face::parse(&bytes, 0).ok()?; fonts.font_data.insert(name.to_owned(), Arc::new(egui::FontData::from_owned(bytes))); for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = fonts.families.entry(family).or_default(); list.retain(|entry| entry != name); list.push(name.to_owned()); } Some(fonts) } /// Install font bytes as fallback for Proportional and Monospace font families. /// Returns true if the font was parsed and installed successfully. pub fn install_cjk_font(ctx: &egui::Context, bytes: Vec) -> bool { match with_cjk_fallback(egui::FontDefinitions::default(), bytes) { Some(fonts) => { ctx.set_fonts(fonts); true } None => false, } } /// Lower-case file-name fragments that identify CJK fonts. Deliberately specific: generic /// words such as "noto" or "gothic" also match Latin-only fonts (NotoSans-Regular, Century Gothic). pub const CJK_FONT_KEYWORDS: &[&str] = &[ "cjk", "notosansjp", "notoserifjp", "bizud", "biz-ud", "yugoth", "meiryo", "msgothic", "hiragino", "pingfang", "songti", "arial unicode", "ipag", "ipaexg", "takao", "vlgothic", "droidsansfallback", ]; /// Whether a font file name looks like a CJK font. pub fn is_cjk_font_name(file_name: &str) -> bool { let name = file_name.to_ascii_lowercase(); CJK_FONT_KEYWORDS.iter().any(|k| name.contains(k)) } /// Lower-case file-name fragments that identify fonts covering simplified Chinese. Deliberately /// separate from [`CJK_FONT_KEYWORDS`]: many Japanese fonts (Hiragino Gothic, Meiryo, Yu Gothic) /// match those but lack simplified characters, so a Chinese fallback searches these instead. pub const CHINESE_FONT_KEYWORDS: &[&str] = &[ "pingfang", "hiragino sans gb", "stheiti", "songti", "arial unicode", "msyh", "yahei", "simhei", "simsun", "wqy", "wenquanyi", "notosanscjk", "notosanssc", "droidsansfallback", "sourcehanss", "siyuan", ]; /// Whether a font file name looks like a font covering simplified Chinese. pub fn is_chinese_font_name(file_name: &str) -> bool { let name = file_name.to_ascii_lowercase(); CHINESE_FONT_KEYWORDS.iter().any(|k| name.contains(k)) } /// Session state of the web app's on-demand CJK font. Pure state so it can be tested off the web: /// the shell does the fetch when [`CjkFontLoader::tick`] asks for it. #[derive(Debug, Default)] pub struct CjkFontLoader { status: CjkFontStatus, /// The saved language while this session falls back to English after a failed load. saved_language: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum CjkFontStatus { #[default] Unloaded, Loading, Installed, Failed, } impl CjkFontLoader { pub fn status(&self) -> CjkFontStatus { self.status } /// Called every frame with the live language. Returns true when the font fetch should start /// now (only for a CJK language, only once). After a failed load a CJK language falls back to /// English for this session; the saved preference is kept (see [`Self::persisted_language`]). pub fn tick(&mut self, language: &mut String) -> bool { if self.saved_language.is_some() && language != "en" { // The user picked a language themselves since the fallback: that is the preference now. self.saved_language = None; } if !needs_cjk_font(language) { return false; } match self.status { CjkFontStatus::Unloaded => { self.status = CjkFontStatus::Loading; true } CjkFontStatus::Failed => { self.fall_back(language); false } CjkFontStatus::Loading | CjkFontStatus::Installed => false, } } /// 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 { self.status = CjkFontStatus::Installed; } else { self.status = CjkFontStatus::Failed; if needs_cjk_font(language) { self.fall_back(language); } } } fn fall_back(&mut self, language: &mut String) { self.saved_language = Some(std::mem::replace(language, "en".to_owned())); } /// The language to write to storage: the user's own choice, not this session's fallback. pub fn persisted_language<'a>(&'a self, live: &'a str) -> &'a str { self.saved_language.as_deref().unwrap_or(live) } } /// Candidate font paths to look for on different operating systems. #[cfg(not(target_arch = "wasm32"))] const FONT_CANDIDATES: &[&str] = &[ // Windows "C:\\Windows\\Fonts\\meiryo.ttc", "C:\\Windows\\Fonts\\YuGothM.ttc", "C:\\Windows\\Fonts\\YuGothR.ttc", "C:\\Windows\\Fonts\\msgothic.ttc", "C:\\Windows\\Fonts\\BIZ-UDGothicR.ttc", "C:\\Windows\\Fonts\\BIZ-UDMinchoM.ttc", "C:\\Windows\\Fonts\\yumin.ttf", // macOS "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", "/System/Library/Fonts/Hiragino Sans W3.ttc", "/System/Library/Fonts/Hiragino Sans W4.ttc", "/System/Library/Fonts/Hiragino Sans W6.ttc", "/System/Library/Fonts/Supplemental/Hiragino Sans W3.ttc", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Songti.ttc", "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/AppleSDGothicNeo.ttc", "/Library/Fonts/Arial Unicode.ttf", "/Library/Fonts/Hiragino Sans W3.ttc", // Linux "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJKjp-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", "/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf", "/usr/share/fonts/truetype/vlgothic/VL-PGothic-Regular.ttf", "/usr/share/fonts/truetype/ipafont/ipag.ttf", "/usr/share/fonts/truetype/ipaexfont/ipaexg.ttf", "/usr/share/fonts/opentype/ipafont/ipag.ttf", "/usr/share/fonts/opentype/ipaexfont/ipaexg.ttf", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", "/usr/share/fonts/cjkuni-uming/uming.ttc", ]; /// Hangul fonts, appended after the CJK fallback so Korean renders even when the CJK font found /// is a Japanese one without Hangul (Meiryo, Hiragino). #[cfg(not(target_arch = "wasm32"))] const HANGUL_FONT_CANDIDATES: &[&str] = &[ // Windows: Malgun Gothic "C:\\Windows\\Fonts\\malgun.ttf", // macOS: Apple SD Gothic Neo "/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf", // Linux: Nanum Gothic (Noto Sans CJK already covers Hangul) "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", "/usr/share/fonts/nanum/NanumGothic.ttf", ]; /// Fonts covering simplified Chinese, appended after the CJK fallback so Chinese renders even /// when the CJK font found is a Japanese one without simplified characters (Hiragino Gothic, /// Meiryo, Yu Gothic). #[cfg(not(target_arch = "wasm32"))] const CHINESE_FONT_CANDIDATES: &[&str] = &[ // Windows: Microsoft YaHei "C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\msyh.ttf", // macOS: PingFang SC, Hiragino Sans GB, Songti SC, STHeiti, Arial Unicode MS "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Supplemental/Songti.ttc", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/Library/Fonts/Arial Unicode.ttf", // Linux: Noto Sans CJK SC "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", ]; #[cfg(not(target_arch = "wasm32"))] fn try_read_font(path: &std::path::Path) -> Option> { if let Ok(bytes) = std::fs::read(path) && ttf_parser::Face::parse(&bytes, 0).is_ok() { Some(bytes) } else { None } } /// Recursively search for matching font files up to a bounded depth, testing names with `matches`. #[cfg(not(target_arch = "wasm32"))] fn scan_dir_for_font(dir: &std::path::Path, depth_remaining: usize, matches: fn(&str) -> bool) -> Option> { if depth_remaining == 0 { return None; } let rd = std::fs::read_dir(dir).ok()?; let mut subdirs = Vec::new(); for entry in rd.flatten() { let p = entry.path(); if p.is_file() { let name = p.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); let is_font = name.ends_with(".ttc") || name.ends_with(".ttf") || name.ends_with(".otf"); if is_font && matches(&name) && let Some(bytes) = try_read_font(&p) { return Some(bytes); } } else if p.is_dir() { subdirs.push(p); } } for sub in subdirs { if let Some(bytes) = scan_dir_for_font(&sub, depth_remaining.saturating_sub(1), matches) { return Some(bytes); } } None } /// Find a CJK fallback font on the local system or in CRAFT_FONTS_DIR. #[cfg(not(target_arch = "wasm32"))] fn find_system_cjk_font() -> Option> { // 1. Check optional craft-fonts pack directory if specified. if let Ok(craft_dir) = std::env::var("CRAFT_FONTS_DIR") { let dir = std::path::Path::new(&craft_dir); let names = ["BIZUDPGothic-Regular.ttf", "BIZ-UDGothicR.ttc", "NotoSansCJK-Regular.ttc", "NotoSansJP-Regular.ttf"]; for name in names { if let Some(bytes) = try_read_font(&dir.join(name)) { return Some(bytes); } } } // 2. Check well-known candidate paths. for path in FONT_CANDIDATES { if let Some(bytes) = try_read_font(std::path::Path::new(path)) { return Some(bytes); } } // 3. Platform-specific dynamic directory search. #[cfg(target_os = "windows")] { if let Ok(windir) = std::env::var("WINDIR").or_else(|_| std::env::var("SystemRoot")) { let fonts_dir = std::path::PathBuf::from(windir).join("Fonts"); let names = ["meiryo.ttc", "YuGothM.ttc", "YuGothR.ttc", "msgothic.ttc", "BIZ-UDGothicR.ttc"]; for name in names { if let Some(bytes) = try_read_font(&fonts_dir.join(name)) { return Some(bytes); } } } if let Ok(local) = std::env::var("LOCALAPPDATA") { let user_fonts = std::path::PathBuf::from(local).join("Microsoft").join("Windows").join("Fonts"); if let Some(bytes) = scan_dir_for_font(&user_fonts, 2, is_cjk_font_name) { return Some(bytes); } } } #[cfg(target_os = "macos")] { let dirs = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"]; for d in dirs { if let Some(bytes) = scan_dir_for_font(std::path::Path::new(d), 2, is_cjk_font_name) { return Some(bytes); } } } #[cfg(target_os = "linux")] { let search_dirs = [std::path::PathBuf::from("/usr/share/fonts"), std::path::PathBuf::from("/usr/local/share/fonts")]; for dir in &search_dirs { if let Some(bytes) = scan_dir_for_font(dir, 3, is_cjk_font_name) { return Some(bytes); } } if let Ok(home) = std::env::var("HOME") { let user_fonts = std::path::PathBuf::from(home).join(".local/share/fonts"); if let Some(bytes) = scan_dir_for_font(&user_fonts, 2, is_cjk_font_name) { return Some(bytes); } } } None } /// The fallback entries in the order they join each family for a UI language. /// /// Chinese goes first for `zh`: a Japanese font (Meiryo, Yu Gothic) lacks the simplified /// characters, so the rest of the word falls through to the Chinese font per glyph — and /// epaint centres each face's own line height in the row, so glyphs from two faces of very /// different height (Meiryo ≈ 2.0 em, Microsoft YaHei ≈ 1.4 em) sit at different heights /// inside one word. One face for the whole word keeps them on one baseline. pub fn fallback_order(language: &str) -> [&'static str; 3] { if language == "zh" { [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK] } else { [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK] } } /// Configure font definitions with system CJK, Chinese and Hangul fallbacks where available, /// ordered for the UI language (see [`fallback_order`]). #[cfg(not(target_arch = "wasm32"))] pub fn setup_fonts_for(ctx: &egui::Context, language: &str) { let cjk = find_system_cjk_font(); let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes)); let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); let mut fonts = egui::FontDefinitions::default(); let mut found = false; for name in fallback_order(language) { let bytes = match name { CJK_FALLBACK => cjk.clone(), CHINESE_FALLBACK => chinese.clone(), _ => hangul.clone(), }; if let Some(bytes) = bytes && let Some(with_fallback) = with_fallback(fonts.clone(), name, bytes) { fonts = with_fallback; found = true; } } if found { ctx.set_fonts(fonts); } } /// Configure font definitions with system fallbacks for an English UI (all fallbacks are /// installed whatever the language, so CJK text in a design still renders; only their order /// follows the language, see [`setup_fonts_for`]). #[cfg(not(target_arch = "wasm32"))] pub fn setup_fonts(ctx: &egui::Context) { setup_fonts_for(ctx, "en"); } /// Find a font covering simplified Chinese in the known candidate paths or by scanning the /// system font directories for [`CHINESE_FONT_KEYWORDS`] names. #[cfg(not(target_arch = "wasm32"))] fn find_system_chinese_font() -> Option> { for path in CHINESE_FONT_CANDIDATES { if let Some(bytes) = try_read_font(std::path::Path::new(path)) { return Some(bytes); } } #[cfg(target_os = "macos")] { let dirs = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"]; for d in dirs { if let Some(bytes) = scan_dir_for_font(std::path::Path::new(d), 2, is_chinese_font_name) { return Some(bytes); } } } None } /// On web, setup_fonts does not perform synchronous disk reads. /// Use asynchronous loading through [`solidcraft_web`] or host scripts. #[cfg(target_arch = "wasm32")] pub fn setup_fonts_for(_ctx: &egui::Context, _language: &str) {} #[cfg(target_arch = "wasm32")] pub fn setup_fonts(_ctx: &egui::Context) {} #[cfg(test)] mod tests { use super::*; fn latin_font_bytes() -> Vec { let defaults = egui::FontDefinitions::default(); let name = &defaults.families[&egui::FontFamily::Proportional][0]; defaults.font_data[name].font.to_vec() } #[test] fn default_ui_language_is_english() { assert_eq!(crate::prefs::Prefs::default().language, "en"); assert!(!needs_cjk_font("en")); } #[test] fn cjk_font_is_a_fallback_after_the_latin_ui_font() { let defaults = egui::FontDefinitions::default(); let fonts = with_cjk_fallback(defaults.clone(), latin_font_bytes()).unwrap(); for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = &fonts.families[&family]; assert_eq!(list.first(), defaults.families[&family].first(), "the Latin UI font stays first"); assert_eq!(list.last().map(String::as_str), Some(CJK_FALLBACK), "CJK is the last fallback"); } assert!(with_cjk_fallback(defaults, b"not a font".to_vec()).is_none()); } #[test] fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() { assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); for lang in ["en", "ja", "ko", "de"] { assert_eq!(fallback_order(lang), [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK], "{lang} keeps the Japanese font first"); } let defaults = egui::FontDefinitions::default(); let mut fonts = defaults.clone(); for name in fallback_order("zh") { fonts = with_fallback(fonts, name, latin_font_bytes()).unwrap(); } for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = &fonts.families[&family]; assert_eq!(list.first(), defaults.families[&family].first(), "the Latin UI font stays first"); assert_eq!(&list[list.len() - 3..], [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); } } #[test] fn hangul_font_falls_back_after_the_cjk_font() { let defaults = egui::FontDefinitions::default(); let cjk = with_cjk_fallback(defaults.clone(), latin_font_bytes()).unwrap(); let fonts = with_fallback(cjk, HANGUL_FALLBACK, latin_font_bytes()).unwrap(); for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = &fonts.families[&family]; assert_eq!(list.first(), defaults.families[&family].first(), "the Latin UI font stays first"); assert_eq!(&list[list.len() - 2..], [CJK_FALLBACK, HANGUL_FALLBACK], "Hangul follows the CJK fallback"); } } #[test] fn directory_scan_matches_cjk_fonts_only() { for cjk in [ "NotoSansCJK-Regular.ttc", "NotoSansJP-Regular.ttf", "BIZUDPGothic-Regular.ttf", "YuGothM.ttc", "meiryo.ttc", "msgothic.ttc", "Hiragino Sans W3.ttc", "ipag.ttf", "TakaoPGothic.ttf", "DroidSansFallbackFull.ttf", ] { assert!(is_cjk_font_name(cjk), "{cjk} is a CJK font"); } for latin in ["NotoSans-Regular.ttf", "NotoSerif-Bold.ttf", "NotoMono-Regular.ttf", "GOTHIC.TTF", "DejaVuSans.ttf", "DroidSans.ttf"] { assert!(!is_cjk_font_name(latin), "{latin} is not a CJK font"); } } #[test] fn web_font_is_fetched_only_for_a_cjk_language() { let mut loader = CjkFontLoader::default(); for lang in ["en", "de"] { let mut language = lang.to_owned(); assert!(!loader.tick(&mut language), "no CJK font fetch for {lang}"); } let mut language = "ja".to_owned(); assert!(loader.tick(&mut language), "Japanese starts the fetch"); assert!(!loader.tick(&mut language), "the fetch starts once"); loader.finished(true, &mut language); assert_eq!(loader.status(), CjkFontStatus::Installed); 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(); let mut language = "ja".to_owned(); assert!(loader.tick(&mut language)); loader.finished(false, &mut language); assert_eq!(language, "en", "this session shows English instead of tofu"); assert_eq!(loader.persisted_language(&language), "ja", "the saved preference stays Japanese"); assert!(!loader.tick(&mut language)); assert_eq!(loader.persisted_language(&language), "ja"); // Choosing a language afterwards makes that the saved preference. language = "de".to_owned(); assert!(!loader.tick(&mut language)); assert_eq!(loader.persisted_language(&language), "de"); } }