diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 450e57347..e10815fa1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 565a4199b..f29dc0188 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -136,7 +136,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -200,7 +200,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - name: Install build and packaging tools run: | @@ -296,7 +296,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: @@ -340,7 +340,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - name: Build in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b45c32223..067337d13 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -59,6 +59,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only | | `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only | | `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only | -| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese UI and document fallback | +| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback | | `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled | | `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled | diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 651fed196..6169b230f 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -2,7 +2,9 @@ //! `docs/integration.md`). With `CRAFT_FONTS_DIR=` the fonts in its //! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web //! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size -//! budget. It only reads the local checkout: no network. +//! budget. With `CRAFT_FONTS_REQUIRED` (releases), a native build also fails when the checkout +//! has no font for a CJK interface language (Japanese, Simplified Chinese), as an outdated pin +//! once did (#241). It only reads the local checkout: no network. use std::fmt::Write as _; use std::path::PathBuf; @@ -12,7 +14,17 @@ fn main() { let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { match craft_fonts(&dir) { - Ok(entries) => src.push_str(&entries), + Ok((entries, covered)) => { + src.push_str(&entries); + // Each CJK interface language needs its own face: without the Chinese one, Chinese + // menus fall back to the Japanese face and show boxes for simplified hanzi (#241). + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() && !wasm { + for s in REQUIRED_SCRIPTS.iter().filter(|s| !covered.iter().any(|c| c == *s)) { + println!("cargo::error=CRAFT_FONTS_DIR={}: no {s} font in fonts/manifest.txt (craft-fonts too old?)", dir.display()); + } + } + } Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); } @@ -26,13 +38,18 @@ fn main() { } } -/// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { +/// Scripts a release build (`CRAFT_FONTS_REQUIRED`, native) must embed a font for: the CJK +/// interface languages' (Japanese, Simplified Chinese). +const REQUIRED_SCRIPTS: &[&str] = &["Jpan", "Hans"]; + +/// One `CraftFont { .. }` initialiser per manifest line, and the scripts the embedded fonts cover. +fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec), String> { let manifest = dir.join("fonts/manifest.txt"); println!("cargo::rerun-if-changed={}", manifest.display()); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut covered = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { @@ -43,6 +60,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); + covered.extend(scripts.split(',').map(|s| s.trim().to_string())); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); let _ = writeln!( out, @@ -51,5 +69,5 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } - Ok(out) + Ok((out, covered)) } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index ca18ba32b..db1180ef1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -329,7 +329,7 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { /// A face's family and style names (the default instance's style for a variable font), and its /// legacy family name (name ID 1) when it has one. -fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option)> { +pub(crate) fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); Some((family, style, name(f, &[StringId::FAMILY_NAME]))) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5d0ca4ff8..2ac6ecae1 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; pub mod math; +mod ui_fallback; pub mod word; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; @@ -17,6 +18,7 @@ use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; +pub use ui_fallback::{SystemUiFont, system_cjk_ui_font, ui_needs_system_cjk}; /// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; /// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs new file mode 100644 index 000000000..5427f0289 --- /dev/null +++ b/crates/fonts/src/ui_fallback.rs @@ -0,0 +1,155 @@ +//! An installed CJK font for the interface when no embedded craft-fonts face covers the interface +//! language (#241): a build without craft-fonts, or one whose craft-fonts checkout lacks the +//! Chinese face, would otherwise draw Chinese menus as boxes. Native only; wasm has no system +//! fonts to read. +//! +//! It looks for a short list of well-known files in the platform's font folders +//! ([`system_font_dirs`](crate::system_font_dirs)) rather than waiting for the full system font +//! scan, since the interface fonts are installed on the first frame. + +use skrifa::MetadataProvider; +use skrifa::raw::FileRef; + +use crate::CraftFont; + +/// An installed font file chosen for the interface. +pub struct SystemUiFont { + /// The face's family name, e.g. `Microsoft YaHei`. + pub family: String, + /// The whole font file. + pub bytes: Vec, + /// The face's index in the file (0 unless it is a collection). + pub index: u32, +} + +/// Do the embedded interface faces (`ui_cjk_fonts`) leave the interface without CJK glyphs it +/// needs? A Chinese interface (`prefer_hans`) needs a Chinese (`Hans`) face: the Japanese one +/// lacks the simplified-only hanzi (页, 选, 删 …). Any other interface still needs some CJK face, +/// for the language names in the Options menu and CJK file names. +pub fn ui_needs_system_cjk(prefer_hans: bool, embedded: &[&CraftFont]) -> bool { + if prefer_hans { !embedded.iter().any(|f| f.scripts.contains(&"Hans")) } else { embedded.is_empty() } +} + +/// Files larger than this are skipped (the big system CJK collections are around 20–80 MB). +const MAX_FILE: u64 = 128 << 20; +/// Faces looked at in one collection. +const MAX_FACES: u32 = 64; + +/// A character only a font for the language has: a simplified-only hanzi, or kana. +fn probe(hans: bool) -> char { + if hans { '页' } else { 'の' } +} + +/// Font files to look for, relative to a font folder, best first. Chinese faces cover kana too, +/// so the Japanese list ends with the Chinese one. +fn candidates(hans: bool) -> Vec<&'static str> { + let (zh, ja): (&[&str], &[&str]) = if cfg!(windows) { + (&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"]) + } else if cfg!(target_os = "macos") { + (&["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"], &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"]) + } else { + ( + &[ + "noto-cjk/NotoSansCJK-Regular.ttc", + "opentype/noto/NotoSansCJK-Regular.ttc", + "google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", + "google-noto-cjk/NotoSansCJK-Regular.ttc", + "noto-cjk/NotoSansCJKsc-Regular.otf", + "opentype/noto/NotoSansCJKsc-Regular.otf", + "adobe-source-han-sans/SourceHanSansCN-Regular.otf", + "wenquanyi/wqy-microhei/wqy-microhei.ttc", + "truetype/wqy/wqy-microhei.ttc", + "wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "truetype/wqy/wqy-zenhei.ttc", + "truetype/droid/DroidSansFallbackFull.ttf", + ], + &["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"], + ) + }; + if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() } +} + +/// The installed CJK font for a Chinese (`hans`) or other interface, if one is found. Reads at +/// most one font file per candidate; never panics on damaged files. +pub fn system_cjk_ui_font(hans: bool) -> Option { + find_ui_font(&crate::system_font_dirs(), &candidates(hans), probe(hans), hans) +} + +/// The first of `names` (tried in order, each in every folder of `dirs`) that is a font file +/// with a face mapping `probe`. +pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], probe: char, hans: bool) -> Option { + names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))).find_map(|path| { + let size = std::fs::metadata(&path).ok().filter(|m| m.is_file())?.len(); + if size > MAX_FILE { + return None; + } + let bytes = std::fs::read(&path).ok()?; + let (index, family) = pick_face(&bytes, probe, hans)?; + log::debug!("interface CJK fallback: {family} ({})", path.display()); + Some(SystemUiFont { family, bytes, index }) + }) +} + +/// The face of a font file (or collection) to use: one that maps `probe`, preferring a Regular +/// style and, for Chinese, a Simplified Chinese family (`… SC`, `… GB`, as in a pan-CJK +/// collection). `None` if the header isn't a font's or no face qualifies. +pub(crate) fn pick_face(data: &[u8], probe: char, hans: bool) -> Option<(u32, String)> { + let magic = data.get(..4)?; + if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) { + return None; + } + let count = match FileRef::new(data).ok()? { + FileRef::Font(_) => 1, + FileRef::Collection(c) => c.len().min(MAX_FACES), + }; + let mut best: Option<((bool, bool), u32, String)> = None; + for i in 0..count { + let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; + if f.charmap().map(probe).is_none() { + continue; + } + let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue }; + let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei"); + let score = (style.eq_ignore_ascii_case("Regular"), regional); + if best.as_ref().is_none_or(|(s, ..)| score > *s) { + best = Some((score, i, family)); + } + } + best.map(|(_, i, family)| (i, family)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn needs_system_cjk_only_when_no_embedded_face_covers_the_language() { + let jp = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }; + let sc = CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }; + // #241: the release embedded only the Japanese faces, and the Chinese interface fell back to them. + assert!(ui_needs_system_cjk(true, &[&jp])); + assert!(!ui_needs_system_cjk(true, &[&sc, &jp])); + assert!(!ui_needs_system_cjk(false, &[&jp])); + assert!(ui_needs_system_cjk(false, &[])); + assert!(ui_needs_system_cjk(true, &[])); + } + + #[test] + fn finds_the_first_valid_font_covering_the_probe() { + let dir = std::env::temp_dir().join(format!("wc-uifallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let sans = crate::bundled()[0]; + // Missing, damaged and non-font files are skipped without panicking. + std::fs::write(dir.join("Deng.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("msyh.ttc"), b"not a font").unwrap(); + std::fs::write(dir.join("sub/good.ttf"), sans).unwrap(); + let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"]; + let found = find_ui_font(std::slice::from_ref(&dir), &names, 'A', true).unwrap(); + assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len())); + // No candidate maps a CJK character: nothing is chosen. + assert!(find_ui_font(std::slice::from_ref(&dir), &names, '页', true).is_none()); + assert!(pick_face(b"OTT", 'A', false).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index bca29587e..83b15f39f 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -389,6 +389,8 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) { /// File ▸ Options ▸ Interface language: follow the system (the default) or pick one (#8). fn language_picker(app: &mut WordApp, ui: &mut Ui) { use crate::i18n::{AUTO, Lang}; + // The language names are in their own scripts: fonts for them load from the next frame. + app.want_system_cjk = true; let system = crate::i18n::system_lang(); let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]); let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() }; diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c38780709..a2f840441 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -107,6 +107,11 @@ impl Lang { self.0.prefer_hans } + /// Is the interface written in CJK script (its own name is), so it needs a CJK face? + pub fn uses_cjk(self) -> bool { + self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c)) + } + /// A language by its exact code (any case). pub fn from_code(code: &str) -> Option { LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 032869a1e..33e766630 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -272,3 +272,24 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() } } } + +#[test] +fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { + // #241: without an embedded Chinese face the interface adds an installed CJK font; egui must + // accept it (it panics on font data it can't parse) and draw simplified-only hanzi with it. + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(true, true)); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let covered = ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "删除页选")); + let embedded = !wordcraft_fonts::ui_needs_system_cjk(true, &wordcraft_fonts::ui_cjk_fonts(true)); + if embedded || wordcraft_fonts::system_cjk_ui_font(true).is_some() { + assert!(covered, "a Chinese face is available but the interface lacks simplified hanzi"); + } +} + +#[test] +fn only_cjk_languages_ask_for_an_installed_cjk_font() { + // #241 follow-up: an English interface must not read a large system CJK font on startup. + let cjk: Vec<&str> = Lang::all().filter(|l| l.uses_cjk()).map(Lang::code).collect(); + assert_eq!(cjk, ["zh-hans", "zh-hant", "ja"]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5983a680a..1107bda76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,11 @@ pub struct WordApp { styled: bool, /// The CJK face order the installed UI fonts use (Chinese first, or Japanese first). fonts_hans: bool, + /// Whether the installed UI fonts include an installed CJK fallback font (#241). + fonts_system_cjk: bool, + /// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in + /// File ▸ Options: load the installed CJK fallback font if no embedded face covers it. + pub(crate) want_system_cjk: bool, fonts_frames: u32, applied_dark: Option, pub frame_ms: f64, @@ -210,6 +215,8 @@ impl WordApp { keytip_rects: Vec::new(), styled: false, fonts_hans: false, + fonts_system_cjk: false, + want_system_cjk: false, fonts_frames: 0, applied_dark: None, frame_ms: 0.0, @@ -649,9 +656,13 @@ impl WordApp { let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). - if !self.styled || lang.prefers_hans() != self.fonts_hans { - theme::install_fonts_for(ctx, lang.prefers_hans()); + // An installed CJK font is read only once CJK text is shown (#241): never for an English + // interface that doesn't open the language list. + let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); + if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk { + theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk); self.fonts_hans = lang.prefers_hans(); + self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. ctx.options_mut(|o| o.zoom_with_keyboard = false); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6bde325d4..23276d12f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -204,11 +204,19 @@ pub fn install_fonts(ctx: &egui::Context) { /// [`install_fonts`] with the CJK fallback order for the interface language: the Chinese face /// first for Chinese (#8), the Japanese one otherwise. The new fonts apply from the next frame. pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { - ctx.set_fonts(font_definitions(prefer_hans)); + install_fonts_with(ctx, prefer_hans, false); } -/// The interface fonts; see [`install_fonts_for`]. -pub fn font_definitions(prefer_hans: bool) -> FontDefinitions { +/// [`install_fonts_for`], also adding an installed CJK font when `system_cjk` is set and no +/// embedded face covers the need (#241). Reading that font is a large file read, so callers ask +/// for it only when CJK text is on screen: a CJK interface language, or the language names in +/// Options. +pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) { + ctx.set_fonts(font_definitions(prefer_hans, system_cjk)); +} + +/// The interface fonts; see [`install_fonts_with`]. +pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); @@ -227,20 +235,44 @@ pub fn font_definitions(prefer_hans: bool) -> FontDefinitions { } fonts.families.insert(FontFamily::Name("medium".into()), vec!["InterMedium".into(), "Inter".into(), "SourceSans".into()]); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["InterSemiBold".into(), "Inter".into(), "SourceSans".into()]); - for f in wordcraft_fonts::ui_cjk_fonts(prefer_hans) { - // The same static bytes the document fonts use: one copy in the binary. - let name = format!("{} {}", f.family, f.style); - fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes))); + let add_cjk = |fonts: &mut FontDefinitions, name: String, data: FontData| { + fonts.font_data.insert(name.clone(), Arc::new(data)); for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { if let Some(v) = fonts.families.get_mut(&fam) { v.push(name.clone()); } } + }; + let cjk = wordcraft_fonts::ui_cjk_fonts(prefer_hans); + for f in &cjk { + // The same static bytes the document fonts use: one copy in the binary. + add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes)); } + // No embedded face covers the interface language (a build without craft-fonts, or without + // its Chinese face, #241): an installed CJK font, so the menus don't show boxes. + #[cfg(not(target_arch = "wasm32"))] + if system_cjk + && wordcraft_fonts::ui_needs_system_cjk(prefer_hans, &cjk) + && let Some(f) = system_cjk_font(prefer_hans) + { + let mut data = FontData::from_static(&f.bytes); + data.index = f.index; + add_cjk(&mut fonts, format!("system {}", f.family), data); + } + #[cfg(target_arch = "wasm32")] + let _ = system_cjk; // Symbols and emoji fall back to egui's defaults (kept in the families). fonts } +/// The installed CJK interface font for a Chinese (`hans`) or other interface, read once. +#[cfg(not(target_arch = "wasm32"))] +fn system_cjk_font(hans: bool) -> Option<&'static wordcraft_fonts::SystemUiFont> { + static ZH: std::sync::OnceLock> = std::sync::OnceLock::new(); + static OTHER: std::sync::OnceLock> = std::sync::OnceLock::new(); + (if hans { &ZH } else { &OTHER }).get_or_init(|| wordcraft_fonts::system_cjk_ui_font(hans)).as_ref() +} + pub fn medium(size: f32) -> FontId { FontId::new(size, FontFamily::Name("medium".into())) }