Web: store translation catalogs and bundled fonts deflated, unpacked on first use (wasm back under the 24 MiB gate)

The 0.6.0 web build was 26.9 MB, over packaging/web/package.sh's 24 MiB gate (Cloudflare's per-file
cap is 25 MiB, #198). The i18n .tsv catalogs (1.85 MB -> 0.64 MB) and the Inter/JetBrains Mono fonts
(1.52 MB -> 0.73 MB) are now deflated by build.rs and inflated on first use, so wasm is 25,141,080
bytes. No visible change. A catalog that fails to inflate falls back to English, a font that fails
falls back to egui's default font; neither panics. Headroom is only ~25 KB: the next size cut is
needed soon (fetching catalogs on demand would save another ~0.64 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-09 22:11:13 -04:00
1 parent e398cc80b8
commit 0c72d95425
12 files changed
+232 -65

No files matched your search

Generated
+1
View File
@@ -4555,6 +4555,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"flate2",
"log",
"photocraft-algo",
"photocraft-cms",
+4
View File
@@ -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 }
+25
View File
@@ -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/<file>.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.
+5 -5
View File
@@ -40,8 +40,8 @@ fn numeric_font(source: &[u8], style: &str, ps: &str) -> Vec<u8> {
#[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::<Vec<_>>(), ["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());
+54 -13
View File
@@ -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<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("Inter-Regular.ttf")));
pub static INTER_MEDIUM: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("Inter-Medium.ttf")));
pub static INTER_SEMIBOLD: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf")));
pub static JETBRAINS_MONO_REGULAR: LazyLock<Vec<u8>> = 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<Vec<u8>>); 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<u8> {
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();
+1 -1
View File
@@ -211,7 +211,7 @@ fn make_ttc(fonts: &[&[u8]]) -> Vec<u8> {
#[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
+4
View File
@@ -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"
+30
View File
@@ -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/<code>.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<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+85 -34
View File
@@ -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<String, String> {
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 {
+21 -10
View File
@@ -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<String> {
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<String> = 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<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
for (fam, primary) in [("medium", medium), ("semibold", semibold)] {
let mut stack: Vec<String> = primary.into_iter().collect();
stack.extend(fallback.iter().cloned());
fonts.families.insert(FontFamily::Name(fam.into()), stack);
}