diff --git a/Cargo.lock b/Cargo.lock index 0a49f3dd4c..8aafd0039e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4555,6 +4555,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "log", "photocraft-algo", "photocraft-cms", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 3d0d8ec6dc..985f627e90 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -27,3 +27,7 @@ flate2 = { workspace = true } # to the generic Unicode rules for those scripts. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } + +[build-dependencies] +# Deflates the bundled fonts (build.rs); `flate2` above inflates them. +flate2 = { workspace = true } diff --git a/crates/text/build.rs b/crates/text/build.rs index 8b5283c240..5377370d7d 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -13,6 +13,9 @@ //! without craft-fonts, 28,867,488 with only the UI face (BIZ UDPGothic Regular) embedded, over //! the 25,165,824-byte gate. The web build loads fonts served next to the wasm instead, on //! demand (`fonts/manifest.txt`, see `served.rs` and packaging/web/README.md › Fonts). +//! +//! Also deflates the bundled UI fonts (`assets/fonts/*.ttf`, `fonts::BUNDLED`) into +//! `OUT_DIR/fonts/.deflate`: 0.73 MB of binary instead of 1.5 MB, for the same size gate. use std::fmt::Write as _; use std::path::PathBuf; @@ -35,6 +38,28 @@ fn main() { if let Err(e) = std::fs::write(&out, src) { println!("cargo::error=writing {}: {e}", out.display()); } + deflate_bundled_fonts(); +} + +/// Deflate the bundled fonts into `OUT_DIR/fonts/` (`fonts::BUNDLED` includes them). +fn deflate_bundled_fonts() { + use std::io::Write as _; + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("fonts"); + let result = (|| -> std::io::Result<()> { + std::fs::create_dir_all(&out)?; + for name in ["Inter-Regular.ttf", "Inter-Medium.ttf", "Inter-SemiBold.ttf", "JetBrainsMono-Regular.ttf"] { + let path = src.join(name); + println!("cargo::rerun-if-changed={}", path.display()); + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&std::fs::read(&path)?)?; + std::fs::write(out.join(format!("{name}.deflate")), z.finish()?)?; + } + Ok(()) + })(); + if let Err(e) = result { + println!("cargo::error=deflating the bundled fonts in {}: {e}", src.display()); + } } /// One `CraftFont { .. }` initialiser per manifest line. diff --git a/crates/text/src/font_names_tests.rs b/crates/text/src/font_names_tests.rs index f5104ac583..becc7a1919 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -40,8 +40,8 @@ fn numeric_font(source: &[u8], style: &str, ps: &str) -> Vec { #[test] fn numeric_subfamilies_with_identical_weights_select_distinct_faces() { let mut db = FontDb::new(); - db.register_font_data(numeric_font(INTER_REGULAR, "20", "UnrelatedPS20")); - db.register_font_data(numeric_font(INTER_SEMIBOLD, "30", "UnrelatedPS30")); + db.register_font_data(numeric_font(&INTER_REGULAR, "20", "UnrelatedPS20")); + db.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "UnrelatedPS30")); let faces = db.faces("Numeric Test"); assert_eq!(faces.iter().map(|f| f.style.as_str()).collect::>(), ["20", "30"]); assert!(faces.iter().all(|f| f.weight == 400.0)); @@ -65,8 +65,8 @@ fn numeric_subfamilies_with_identical_weights_select_distinct_faces() { fn numeric_styles_shape_different_outlines_and_keep_psd_identity() { use photocraft_doc::{TextLayer, text::TextRun}; let mut engine = crate::TextEngine::new(); - engine.fonts.register_font_data(numeric_font(INTER_REGULAR, "20", "UnrelatedPS20")); - engine.fonts.register_font_data(numeric_font(INTER_SEMIBOLD, "30", "UnrelatedPS30")); + engine.fonts.register_font_data(numeric_font(&INTER_REGULAR, "20", "UnrelatedPS20")); + engine.fonts.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "UnrelatedPS30")); let layer = |style: &str| TextLayer { text: "Hamburgefonstiv".into(), runs: vec![TextRun { @@ -107,7 +107,7 @@ fn explicit_weight_is_not_overridden_by_a_stale_style_name() { #[test] fn collection_isolation_preserves_requested_face() { // Two faces with identical attributes: the second must not fall back to the first. - let sources = [numeric_font(INTER_REGULAR, "20", "Collection20"), numeric_font(INTER_SEMIBOLD, "30", "Collection30")]; + let sources = [numeric_font(&INTER_REGULAR, "20", "Collection20"), numeric_font(&INTER_SEMIBOLD, "30", "Collection30")]; let mut bytes = b"ttcf\0\x01\0\0\0\0\0\x02".to_vec(); bytes.extend(20u32.to_be_bytes()); bytes.extend((20 + sources[0].len() as u32).to_be_bytes()); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 1dab0b8c48..0db728017e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -4,8 +4,8 @@ //! data, and PostScript-name lookup for PSD import. use std::collections::HashMap; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; use parley::FontContext; use parley::fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache}; @@ -17,21 +17,44 @@ pub const DEFAULT_FAMILY: &str = "Inter"; /// Bundled monospace family. pub const MONO_FAMILY: &str = "JetBrains Mono"; +/// An `assets/fonts` file as deflated by build.rs. +macro_rules! bundled_font { + ($file:literal) => { + include_bytes!(concat!(env!("OUT_DIR"), "/fonts/", $file, ".deflate")) + }; +} + // The font files are embedded once, here. The UI (`ui-egui` theme) uses these same statics: // a second `include_bytes!` of the same file elsewhere put a second 1.5 MB copy into the wasm. -pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); -pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); -pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); +// They are stored deflated by build.rs (0.73 MB instead of 1.5 MB: the web build's 24 MiB size +// gate, packaging/web/package.sh) and inflated on first use, once per process. +pub static INTER_REGULAR: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("Inter-Regular.ttf"))); +pub static INTER_MEDIUM: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("Inter-Medium.ttf"))); +pub static INTER_SEMIBOLD: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf"))); +pub static JETBRAINS_MONO_REGULAR: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("JetBrainsMono-Regular.ttf"))); /// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`). -pub static BUNDLED: &[(&str, &[u8])] = &[ - ("Inter-Regular.ttf", INTER_REGULAR), - ("Inter-Medium.ttf", INTER_MEDIUM), - ("Inter-SemiBold.ttf", INTER_SEMIBOLD), - ("JetBrainsMono-Regular.ttf", JETBRAINS_MONO_REGULAR), +pub static BUNDLED: [(&str, &LazyLock>); 4] = [ + ("Inter-Regular.ttf", &INTER_REGULAR), + ("Inter-Medium.ttf", &INTER_MEDIUM), + ("Inter-SemiBold.ttf", &INTER_SEMIBOLD), + ("JetBrainsMono-Regular.ttf", &JETBRAINS_MONO_REGULAR), ]; +/// Inflated fonts are bounded (the largest is 0.42 MB), so corrupt data can't exhaust memory. +const MAX_BUNDLED_FONT_BYTES: u64 = 16 << 20; + +/// Inflate a bundled font. Data that doesn't inflate gives no font (empty bytes, which every +/// consumer skips), never a panic; `bundled_fonts_inflate_to_their_files` keeps it from happening. +fn inflate_font(deflated: &[u8]) -> Vec { + use std::io::Read as _; + let mut out = Vec::new(); + match flate2::read::DeflateDecoder::new(deflated).take(MAX_BUNDLED_FONT_BYTES + 1).read_to_end(&mut out) { + Ok(_) if out.len() as u64 <= MAX_BUNDLED_FONT_BYTES => out, + _ => Vec::new(), + } +} + /// Families tried (if installed) after the requested one, for missing glyphs. The Thai and CJK /// families ([`THAI_FAMILIES`], [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"]; @@ -124,7 +147,7 @@ impl FontDb { face_aliases: HashMap::new(), }; for (_, bytes) in BUNDLED { - db.register_font_data(bytes.to_vec()); + db.register_static_font(bytes.as_slice()); } // The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on // wasm32), before any system font. @@ -560,7 +583,7 @@ mod tests { let czech = "aábcčdďeéěfghiíjklmnňoópqrřsštťuúůvwxyýzž„“"; let letters: String = czech.chars().chain(czech.chars().flat_map(char::to_uppercase)).collect(); for (name, bytes) in super::BUNDLED { - let font = skrifa::FontRef::new(bytes).expect("bundled font parses"); + let font = skrifa::FontRef::new(bytes.as_slice()).expect("bundled font parses"); let cmap = font.charmap(); let missing: String = letters.chars().filter(|c| cmap.map(*c).is_none_or(|g| g.to_u32() == 0)).collect(); assert!(missing.is_empty(), "{name} lacks Czech glyphs: {missing}"); @@ -575,13 +598,31 @@ mod tests { let french = "àâæçéèêëîïôœùûüÿ"; let letters: String = french.chars().chain(french.chars().flat_map(char::to_uppercase)).chain("«»\u{a0}’".chars()).collect(); for (name, bytes) in super::BUNDLED { - let font = skrifa::FontRef::new(bytes).expect("bundled font parses"); + let font = skrifa::FontRef::new(bytes.as_slice()).expect("bundled font parses"); let cmap = font.charmap(); let missing: String = letters.chars().filter(|c| cmap.map(*c).is_none_or(|g| g.to_u32() == 0)).collect(); assert!(missing.is_empty(), "{name} lacks French glyphs: {missing:?}"); } } + /// The deflated fonts in the binary inflate to exactly the files in `assets/fonts`. + #[test] + fn bundled_fonts_inflate_to_their_files() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + for (name, bytes) in super::BUNDLED { + assert_eq!(bytes.as_slice(), std::fs::read(dir.join(name)).unwrap(), "{name}"); + } + } + + /// Corrupt or oversized font data gives no font, not a panic. + #[test] + fn corrupt_bundled_font_data_gives_no_font() { + assert!(super::inflate_font(b"\xff\xff not deflate").is_empty()); + assert!(super::inflate_font(b"").is_empty()); + let mut db = super::FontDb::new(); + assert!(db.register_font_data(super::inflate_font(b"\x00garbage")).is_empty()); + } + #[test] fn missing_font_dirs_are_skipped() { let mut files = Vec::new(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c9a78259e7..46c1e26f08 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -211,7 +211,7 @@ fn make_ttc(fonts: &[&[u8]]) -> Vec { #[test] fn truetype_collections_load() { - let ttc = make_ttc(&[fonts::BUNDLED[0].1, fonts::BUNDLED[3].1]); + let ttc = make_ttc(&[fonts::BUNDLED[0].1.as_slice(), fonts::BUNDLED[3].1.as_slice()]); assert_eq!(fonts::face_count(&ttc), 2); let mut db = fonts::FontDb::new(); // A fresh DB without the bundled mono font would miss it; register the collection anyway diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f6b3dd24f2..7512860b4d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -37,6 +37,8 @@ base64 = "0.22" # Edit › Keyboard Shortcuts › Import Photoshop Shortcuts… reads Photoshop's `.kys` XML # (already in the tree through egui_extras → resvg → usvg). roxmltree = "0.20" +# Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io. +flate2 = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } @@ -53,5 +55,7 @@ photocraft-testkit = { workspace = true } photocraft-psd = { workspace = true } [build-dependencies] +# Deflates the translation catalogs (build.rs); already linked at run time to inflate them. +flate2 = { workspace = true } serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index c5276ebf5c..fa0e19cc1f 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -7,6 +7,11 @@ //! JSON says the entry was verified (`people_entry`). //! //! A missing or malformed file gives empty tables (or no names) and a warning. +//! +//! Also deflates the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/.tsv.deflate` +//! for `i18n::LANGUAGES`: stored compressed they take ~0.65 MB of the binary instead of ~1.9 MB, +//! which keeps the web build under its 24 MiB size gate (packaging/web/package.sh). A catalog is +//! inflated the first time its language is used. use std::fmt::Write as _; @@ -22,6 +27,31 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } + deflate_catalogs(); +} + +/// Deflate every `src/i18n/*.tsv` into `OUT_DIR/i18n/`. A catalog that can't be written is a +/// build error (`include_bytes!` would fail on it anyway, with a less helpful message). +fn deflate_catalogs() { + use std::io::Write as _; + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n"); + println!("cargo::rerun-if-changed={}", src.display()); + let result = (|| -> std::io::Result<()> { + std::fs::create_dir_all(&out)?; + for entry in std::fs::read_dir(&src)? { + let path = entry?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue }; + println!("cargo::rerun-if-changed={}", path.display()); + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&std::fs::read(&path)?)?; + std::fs::write(out.join(format!("{name}.deflate")), z.finish()?)?; + } + Ok(()) + })(); + if let Err(e) = result { + println!("cargo::error=deflating the translation catalogs in {}: {e}", src.display()); + } } fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { diff --git a/crates/ui-egui/src/cjk_fonts.rs b/crates/ui-egui/src/cjk_fonts.rs index 73f4aa5864..80df017a98 100644 --- a/crates/ui-egui/src/cjk_fonts.rs +++ b/crates/ui-egui/src/cjk_fonts.rs @@ -312,7 +312,7 @@ mod tests { ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); crate::theme::set_ui_font_size(&ctx, UiFontSize::Large); let name = "test-lazy-fallback".to_string(); - add_to_all_families(&ctx, name.clone(), FontData::from_static(photocraft_text::fonts::INTER_REGULAR)); + add_to_all_families(&ctx, name.clone(), FontData::from_static(photocraft_text::fonts::INTER_REGULAR.as_slice())); for (size, scale) in [(UiFontSize::Large, 16.0 / 12.0), (UiFontSize::Tiny, 10.0 / 12.0), (UiFontSize::Small, 1.0)] { crate::theme::set_ui_font_size(&ctx, size); for _ in 0..2 { diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 4f34924781..9c1f69caab 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -134,7 +134,7 @@ fn korean_glyphs_remain_available_after_repeated_language_switches() { } with_language(Lang::EN, || { let mut h = harness(); - let glyphs: std::collections::BTreeSet<_> = lang("ko").0.source.chars().filter(|c| cjk::classify(*c) == Some(CjkChar::Hangul)).collect(); + let glyphs: std::collections::BTreeSet<_> = lang("ko").0.text().chars().filter(|c| cjk::classify(*c) == Some(CjkChar::Hangul)).collect(); for code in ["zh-hans", "ja", "ko", "fr", "ko"] { h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": code})).expect("language"); h.run_steps(12); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 2f5af1a7fd..2b89f59e73 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -34,8 +34,9 @@ pub struct LangInfo { pub code: &'static str, /// The language's name in itself, shown in the Preferences dropdown. pub name: &'static str, - /// Catalog file contents (empty for the built-in English). - pub source: &'static str, + /// Catalog file contents, raw-deflated by build.rs (empty for the built-in English); see + /// [`LangInfo::text`]. + pub source: &'static [u8], /// Plural form index for a count (English: 0 = one, 1 = other; Japanese and Chinese: always 0; /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// index. @@ -95,48 +96,68 @@ fn plural_polish(n: u64) -> usize { } } +/// A catalog file as deflated by build.rs (the binary would otherwise carry ~1.9 MB of +/// translations, which the web build's size gate can't afford). +macro_rules! catalog { + ($code:literal) => { + include_bytes!(concat!(env!("OUT_DIR"), "/i18n/", $code, ".tsv.deflate")) + }; +} + +/// Inflated catalogs are bounded, so corrupt data can't exhaust memory (the largest is ~0.2 MB). +const MAX_CATALOG_BYTES: u64 = 16 << 20; + +/// Inflate a catalog. Bad data gives an error (and the language falls back to English), never a panic. +fn inflate(source: &[u8]) -> Result { + use std::io::Read as _; + let mut text = String::new(); + if source.is_empty() { + return Ok(text); + } + flate2::read::DeflateDecoder::new(source).take(MAX_CATALOG_BYTES + 1).read_to_string(&mut text).map_err(|e| e.to_string())?; + if text.len() as u64 > MAX_CATALOG_BYTES { + return Err(format!("over {MAX_CATALOG_BYTES} bytes")); + } + Ok(text) +} + /// The registry. English first: it is the fallback and the source language. pub static LANGUAGES: [LangInfo; 17] = [ - LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, - LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { - code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() - }, + LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, + LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` // locales all resolve here (see `candidates`). - LangInfo { - code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() - }, - LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "zh-hant", name: "繁體中文", source: catalog!("zh-hant"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "es", name: "Español", source: catalog!("es"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "ru", name: "Русский", source: catalog!("ru"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, // Ukrainian has the same one/few/many rule for integer counts. - LangInfo { - code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() - }, - LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "id", name: "Bahasa Indonesia", source: include_str!("id.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "uk", name: "Українська", source: catalog!("uk"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "ko", name: "한국어", source: catalog!("ko"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "pl", name: "Polski", source: catalog!("pl"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "de", name: "Deutsch", source: catalog!("de"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, // Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`). - LangInfo { - code: "pt-br", - name: "Português (Brasil)", - source: include_str!("pt-br.tsv"), - plural: plural_pt, - complete_menus: true, - catalog: OnceLock::new(), - }, - LangInfo { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "pt-br", name: "Português (Brasil)", source: catalog!("pt-br"), plural: plural_pt, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "el", name: "Ελληνικά", source: catalog!("el"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, // Dutch; `nl-NL` and `nl-BE` locales both resolve here. - LangInfo { code: "nl", name: "Nederlands", source: include_str!("nl.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, - LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { + /// The catalog file's text (empty for English, or if the bundled data can't be inflated). + pub fn text(&self) -> String { + inflate(self.source).unwrap_or_else(|e| { + log::error!("translation catalog {}: {e}; showing English", self.code); + String::new() + }) + } + fn catalog(&self) -> &Catalog { - self.catalog.get_or_init(|| Catalog::parse(self.source)) + self.catalog.get_or_init(|| Catalog::parse(&self.text())) } } @@ -618,12 +639,42 @@ mod tests { assert_eq!(tr(CS(), "All"), "Vše"); } + /// The deflated catalogs in the binary inflate to exactly the `.tsv` files. + #[test] + fn deflated_catalogs_match_their_files() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + for l in LANGUAGES.iter().skip(1) { + let file = std::fs::read_to_string(dir.join(format!("{}.tsv", l.code))).unwrap(); + assert_eq!(inflate(l.source).unwrap(), file, "{}", l.code); + assert!(l.source.len() < file.len() / 2, "{}: stored deflated", l.code); + } + assert_eq!(LANGUAGES[0].text(), "", "English has no catalog"); + } + + /// Corrupt catalog data is an error (English is shown), not a panic. + #[test] + fn corrupt_catalog_data_is_an_error() { + assert!(inflate(b"\xff\xff\xff\xff not deflate").is_err()); + let ja = LANGUAGES.iter().find(|l| l.code == "ja").unwrap(); + assert!(inflate(&ja.source[..ja.source.len() / 2]).is_err(), "truncated"); + let info: &'static LangInfo = Box::leak(Box::new(LangInfo { + code: "xx", + name: "x", + source: b"\x00garbage", + plural: plural_none, + complete_menus: false, + catalog: OnceLock::new(), + })); + assert_eq!(info.text(), ""); + assert_eq!(tr(Lang(info), "Layer"), "Layer", "falls back to English"); + } + /// Every bundled catalog is well-formed and consistent with its sources. #[test] fn bundled_catalogs_are_consistent() { for l in &LANGUAGES { assert!(l.code == l.code.to_ascii_lowercase() && !l.name.is_empty(), "{}", l.code); - let (entries, errors) = parse_entries(l.source); + let (entries, errors) = parse_entries(&l.text()); assert!(errors.is_empty(), "{}: {errors:?}", l.code); let mut seen = std::collections::HashSet::new(); for (ctx, src, tr) in &entries { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b8db932cc2..2d4910a9da 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -535,19 +535,30 @@ pub fn install_fonts(ctx: &egui::Context) { /// [`install_fonts`] with the CJK fallback fonts taken from `cjk` (tests swap the sources). pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) { let mut fonts = FontDefinitions::default(); - let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + // The bundled fonts are inflated on first use (photocraft_text::fonts). Should one ever fail + // to inflate (empty bytes), it is left out and egui's default fonts draw that text. + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| -> Option { + if bytes.is_empty() { + log::error!("bundled font {name} is unavailable; using the default UI font"); + return None; + } fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + Some(name.to_owned()) }; - add(&mut fonts, "Inter", photocraft_text::fonts::INTER_REGULAR); - add(&mut fonts, "Inter-Medium", photocraft_text::fonts::INTER_MEDIUM); - add(&mut fonts, "Inter-SemiBold", photocraft_text::fonts::INTER_SEMIBOLD); - add(&mut fonts, "JetBrainsMono", photocraft_text::fonts::JETBRAINS_MONO_REGULAR); - fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned()); - fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned()); + let inter = add(&mut fonts, "Inter", photocraft_text::fonts::INTER_REGULAR.as_slice()); + let medium = add(&mut fonts, "Inter-Medium", photocraft_text::fonts::INTER_MEDIUM.as_slice()); + let semibold = add(&mut fonts, "Inter-SemiBold", photocraft_text::fonts::INTER_SEMIBOLD.as_slice()); + let mono = add(&mut fonts, "JetBrainsMono", photocraft_text::fonts::JETBRAINS_MONO_REGULAR.as_slice()); + if let Some(inter) = inter { + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, inter); + } + if let Some(mono) = mono { + fonts.families.entry(FontFamily::Monospace).or_default().insert(0, mono); + } // Named weights fall back to the default stack for missing glyphs. - let fallback: Vec = fonts.families[&FontFamily::Proportional].clone(); - for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] { - let mut stack = vec![primary.to_owned()]; + let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + for (fam, primary) in [("medium", medium), ("semibold", semibold)] { + let mut stack: Vec = primary.into_iter().collect(); stack.extend(fallback.iter().cloned()); fonts.families.insert(FontFamily::Name(fam.into()), stack); }