mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 07:25:20 +02:00
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:
1 parent
e398cc80b8
commit
0c72d95425
12 files changed
+232
-65
No files matched your search
Generated
+1
@@ -4555,6 +4555,7 @@ dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"flate2",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user