diff --git a/Cargo.lock b/Cargo.lock index be740d0e..7bcedc1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4575,6 +4575,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "epaint_default_fonts", "flate2", "log", "photocraft-algo", diff --git a/Cargo.toml b/Cargo.toml index 6b13954f..f075c89d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,9 @@ zune-core = "0.5" zune-jpeg = "0.5" proptest = "1" eframe = { version = "0.36", default-features = false } -egui = "0.36" +# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated +# (egui_fonts.rs; the web build's size gate). +egui = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" log = "0.4" @@ -81,13 +83,14 @@ codegen-units = 1 # The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the # size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every -# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O -# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic -# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for -# the native app shell). packaging/web/package.sh fails the build above 24 MiB. +# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates, +# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters +# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured). +# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` +# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB. [profile.wasm-release] inherits = "release" -opt-level = "s" +opt-level = "z" lto = "fat" codegen-units = 1 strip = "debuginfo" @@ -95,19 +98,15 @@ strip = "debuginfo" [profile.wasm-release.package."*"] opt-level = "s" -# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below -# retain their speed-oriented optimization level. -[profile.wasm-release.package.photocraft-ui-egui] -opt-level = "z" - -# Keep browser command dispatch, import/export glue, and the entry point compact. Native -# builds and the speed-oriented pixel-processing overrides below retain their settings. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" -[profile.wasm-release.package.photocraft-io] -opt-level = "z" -[profile.wasm-release.package.photocraft-web] -opt-level = "z" +# Decoding and rasterizing keep their speed. +[profile.wasm-release.package.photocraft-codecs] +opt-level = "s" +[profile.wasm-release.package.photocraft-raw] +opt-level = "s" +[profile.wasm-release.package.photocraft-heif] +opt-level = "s" +[profile.wasm-release.package.photocraft-vector] +opt-level = "s" # Large crates off the per-frame and per-pixel paths: the document model, file parsers run once # per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation @@ -127,6 +126,7 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" +# Pixel processing keeps its speed-oriented optimization level. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 6b4c6279..a4ded641 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -29,7 +29,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit"] } rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 740a75b3..57b53793 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -29,7 +29,7 @@ photocraft-automation = { workspace = true } photocraft-ui-egui = { workspace = true } # Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate). photocraft-tablet = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 56872716..d6a49c00 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,7 +30,7 @@ photocraft-format = { workspace = true } photocraft-codecs = { workspace = true } egui = { workspace = true } egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -49,6 +49,9 @@ sys-locale = "=0.3.2" winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } [dev-dependencies] +# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with +# `Context::default()` (no install_fonts) can lay out text. +egui = { workspace = true, features = ["default_fonts"] } # Synthetic raw files for the open-time Camera Raw dialog tests. photocraft-raw = { workspace = true, features = ["testgen"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } @@ -58,7 +61,9 @@ photocraft-testkit = { workspace = true } photocraft-psd = { workspace = true } [build-dependencies] -# Deflates the translation catalogs (build.rs); already linked at run time to inflate them. +# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them. flate2 = { workspace = true } +# egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses). +epaint_default_fonts = "0.36" serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index fa0e19cc..900370eb 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -8,10 +8,10 @@ //! //! 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. +//! Also packs the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/` for +//! `i18n::LANGUAGES` (see [`pack_catalogs`]): stored that way they take ~0.5 MB of the binary +//! instead of ~2.2 MB, which keeps the web build under its 24 MiB size gate +//! (packaging/web/package.sh). A catalog is unpacked the first time its language is used. use std::fmt::Write as _; @@ -27,30 +27,123 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } - deflate_catalogs(); + pack_catalogs(); + deflate_egui_fonts(); } -/// 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() { +/// Deflate egui's default fonts (the `epaint_default_fonts` crate) into `OUT_DIR/egui_fonts/` for +/// `egui_fonts.rs`. The app builds egui without `default_fonts`, which would embed the same 1.4 MB +/// uncompressed; deflated they take 0.78 MB (the web build's 24 MiB size gate). +fn deflate_egui_fonts() { 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 out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts"); + let fonts = [ + ("Hack-Regular", epaint_default_fonts::HACK_REGULAR), + ("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR), + ("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT), + ("emoji-icon-font", epaint_default_fonts::EMOJI_ICON), + ]; 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()); + for (name, bytes) in fonts { 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()?)?; + z.write_all(bytes)?; + std::fs::write(out.join(format!("{name}.ttf.deflate")), z.finish()?)?; } Ok(()) })(); if let Err(e) = result { - println!("cargo::error=deflating the translation catalogs in {}: {e}", src.display()); + println!("cargo::error=deflating egui's default fonts into {}: {e}", out.display()); + } +} + +/// Whether a catalog line is an entry (`contextsourcetranslation`, source and +/// translation non-empty, as `i18n::catalog::parse_entries` reads it), and its key and translation. +fn catalog_entry(line: &str) -> Option<(&str, &str)> { + if line.starts_with('#') { + return None; + } + let mut cols = line.splitn(3, '\t'); + let (ctx, src, tr) = (cols.next()?, cols.next()?, cols.next()?); + let key = line.get(..ctx.len() + 1 + src.len())?; + (!src.is_empty() && !tr.is_empty() && !tr.contains('\t')).then_some((key, tr)) +} + +/// Pack every `src/i18n/*.tsv` into `OUT_DIR/i18n/`, losslessly (`i18n::unpack` restores each file +/// byte for byte). Every catalog repeats the same English sources, so they are stored once: +/// +/// - `keys.deflate`: every entry's `contextsource`, one per line, in order of first +/// appearance (the largest catalog first). +/// - `.tsv.deflate`: the catalog, each entry line replaced by `\x01` when its key +/// follows the previous entry's in `keys`, else `\x02` (the offset from +/// there). Other lines are kept; one that starts with \x01, \x02 or \x03 gets a \x03 prefix. +/// +/// Both are deflated. A catalog that can't be written is a build error (`include_bytes!` would +/// fail on it anyway, with a less helpful message). +fn pack_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 deflate = |bytes: &[u8]| -> std::io::Result> { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(bytes)?; + z.finish() + }; + let result = (|| -> std::io::Result<()> { + std::fs::create_dir_all(&out)?; + let mut catalogs = Vec::new(); + 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()); + catalogs.push((name.to_owned(), std::fs::read_to_string(&path)?)); + } + // Largest first, then by name: the key order (and so the output) is reproducible. + catalogs.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + let mut keys: Vec<&str> = Vec::new(); + let mut index = std::collections::HashMap::new(); + for line in catalogs.iter().flat_map(|(_, text)| text.split('\n')) { + if let Some((key, _)) = catalog_entry(line) { + index.entry(key).or_insert_with(|| { + keys.push(key); + keys.len() - 1 + }); + } + } + std::fs::write(out.join("keys.deflate"), deflate(keys.join("\n").as_bytes())?)?; + for (name, text) in &catalogs { + let mut packed = String::with_capacity(text.len()); + let mut next = 0usize; + for (n, line) in text.split('\n').enumerate() { + if n > 0 { + packed.push('\n'); + } + match catalog_entry(line) { + Some((key, tr)) => { + let i = index.get(key).copied().unwrap_or_default(); + if i == next { + packed.push('\x01'); + } else { + let _ = write!(packed, "\x02{}\t", i as i64 - next as i64); + } + packed.push_str(tr); + next = i + 1; + } + None => { + if line.starts_with(['\x01', '\x02', '\x03']) { + packed.push('\x03'); + } + packed.push_str(line); + } + } + } + std::fs::write(out.join(format!("{name}.deflate")), deflate(packed.as_bytes())?)?; + } + Ok(()) + })(); + if let Err(e) = result { + println!("cargo::error=packing the translation catalogs in {}: {e}", src.display()); } } diff --git a/crates/ui-egui/src/egui_fonts.rs b/crates/ui-egui/src/egui_fonts.rs new file mode 100644 index 00000000..4c71705e --- /dev/null +++ b/crates/ui-egui/src/egui_fonts.rs @@ -0,0 +1,91 @@ +//! egui's default fonts (Ubuntu Light, Hack, Noto Emoji and emoji-icon-font: the fallback stack +//! behind Inter and JetBrains Mono, for symbols and emoji), stored deflated. +//! +//! egui's `default_fonts` feature embeds them uncompressed (1.4 MB), which the web build's 24 MiB +//! size gate (packaging/web/package.sh) can't afford. The app builds egui without it; build.rs +//! deflates the same files from the `epaint_default_fonts` crate (0.78 MB) and they are inflated +//! once, the first time the fonts are installed. Test builds keep the feature (dev-dependencies), +//! so egui's own definitions are the oracle for [`definitions`]. + +use std::sync::{Arc, LazyLock}; + +use egui::{FontData, FontDefinitions, FontFamily, FontTweak}; + +/// A font deflated by build.rs. +macro_rules! deflated { + ($name:literal) => { + include_bytes!(concat!(env!("OUT_DIR"), "/egui_fonts/", $name, ".ttf.deflate")) + }; +} + +static HACK: LazyLock> = LazyLock::new(|| inflate(deflated!("Hack-Regular"))); +static NOTO_EMOJI: LazyLock> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular"))); +static UBUNTU_LIGHT: LazyLock> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light"))); +static EMOJI_ICON: LazyLock> = LazyLock::new(|| inflate(deflated!("emoji-icon-font"))); + +/// Inflated fonts are bounded (the largest is 0.42 MB), so corrupt data can't exhaust memory. +const MAX_FONT_BYTES: u64 = 16 << 20; + +/// Inflate a font. Data that doesn't inflate gives empty bytes (the font is left out), never a panic. +fn inflate(deflated: &[u8]) -> Vec { + use std::io::Read as _; + let mut out = Vec::new(); + match flate2::read::DeflateDecoder::new(deflated).take(MAX_FONT_BYTES + 1).read_to_end(&mut out) { + Ok(_) if out.len() as u64 <= MAX_FONT_BYTES => out, + _ => Vec::new(), + } +} + +/// `FontDefinitions::default()` with egui's default fonts, whether or not egui was built with +/// `default_fonts`: the same faces, tweaks and family order. +pub fn definitions() -> FontDefinitions { + let fonts = FontDefinitions::default(); + if fonts.font_data.is_empty() { from_deflated() } else { fonts } +} + +/// egui's default definitions, built from the deflated copies. +fn from_deflated() -> FontDefinitions { + let mut fonts = FontDefinitions::empty(); + let faces: [(&str, &'static [u8], f32); 4] = [ + ("Hack", HACK.as_slice(), 1.0), + // egui draws its emoji fonts a little smaller. + ("NotoEmoji-Regular", NOTO_EMOJI.as_slice(), 0.81), + ("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0), + ("emoji-icon-font", EMOJI_ICON.as_slice(), 0.90), + ]; + for (name, bytes, scale) in faces { + if bytes.is_empty() { + log::error!("egui default font {name} is unavailable"); + continue; + } + let data = FontData::from_static(bytes).tweak(FontTweak { scale, ..Default::default() }); + fonts.font_data.insert(name.to_owned(), Arc::new(data)); + } + let stack = |names: &[&str]| -> Vec { names.iter().filter(|n| fonts.font_data.contains_key(**n)).map(|n| (*n).to_owned()).collect() }; + let mono = stack(&["Hack", "Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]); + let proportional = stack(&["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]); + fonts.families.insert(FontFamily::Monospace, mono); + fonts.families.insert(FontFamily::Proportional, proportional); + fonts +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The definitions built from the deflated copies are egui's own: the same bytes, tweaks and + /// family order (test builds enable `default_fonts`, so `FontDefinitions::default()` is egui's). + #[test] + fn deflated_fonts_match_egui_defaults() { + let egui = FontDefinitions::default(); + assert!(!egui.font_data.is_empty(), "test builds enable egui's default_fonts (dev-dependencies)"); + assert_eq!(from_deflated(), egui); + assert_eq!(definitions(), egui); + } + + #[test] + fn corrupt_font_data_is_left_out_not_a_panic() { + assert!(inflate(b"not deflate data").is_empty()); + assert!(inflate(&[]).is_empty()); + } +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index fe6e8e68..bdf4ef55 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -115,8 +115,8 @@ 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). +/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB +/// of translations, which the web build's size gate can't afford). See [`unpack`]. macro_rules! catalog { ($code:literal) => { include_bytes!(concat!(env!("OUT_DIR"), "/i18n/", $code, ".tsv.deflate")) @@ -140,6 +140,52 @@ fn inflate(source: &[u8]) -> Result { Ok(text) } +/// Every catalog entry's `contextsource`, stored once for all catalogs (build.rs). +static KEYS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/i18n/keys.deflate")); + +/// Restore a catalog file from its packed form (build.rs `pack_catalogs`): entry lines refer to +/// [`KEYS`] instead of repeating the English source. Bad data gives an error (and the language +/// falls back to English), never a panic. +fn unpack(source: &[u8]) -> Result { + static KEY_TEXT: OnceLock> = OnceLock::new(); + if source.is_empty() { + return Ok(String::new()); + } + let keys: Vec<&str> = KEY_TEXT.get_or_init(|| inflate(KEYS)).as_ref().map_err(|e| format!("catalog keys: {e}"))?.split('\n').collect(); + expand(&inflate(source)?, &keys) +} + +/// [`unpack`]'s second step: replace each packed entry line with its key and translation. +fn expand(packed: &str, keys: &[&str]) -> Result { + let mut text = String::with_capacity(packed.len().saturating_mul(2)); + let mut next = 0usize; + for (n, line) in packed.split('\n').enumerate() { + if n > 0 { + text.push('\n'); + } + let bad = || format!("packed line {}: bad key reference", n + 1); + let (index, tr) = if let Some(tr) = line.strip_prefix('\x01') { + (next, tr) + } else if let Some(rest) = line.strip_prefix('\x02') { + let (offset, tr) = rest.split_once('\t').ok_or_else(bad)?; + let offset: i64 = offset.parse().map_err(|_| bad())?; + let index = i64::try_from(next).ok().and_then(|n| n.checked_add(offset)).and_then(|i| usize::try_from(i).ok()).ok_or_else(bad)?; + (index, tr) + } else { + text.push_str(line.strip_prefix('\x03').unwrap_or(line)); + continue; + }; + text.push_str(keys.get(index).ok_or_else(bad)?); + text.push('\t'); + text.push_str(tr); + next = index.saturating_add(1); + 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; 20] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, @@ -175,7 +221,7 @@ pub static LANGUAGES: [LangInfo; 20] = [ 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| { + unpack(self.source).unwrap_or_else(|e| { log::error!("translation catalog {}: {e}; showing English", self.code); String::new() }) @@ -702,13 +748,13 @@ mod tests { assert_eq!(tr(CS(), "All"), "Vše"); } - /// The deflated catalogs in the binary inflate to exactly the `.tsv` files. + /// The packed catalogs in the binary unpack to exactly the `.tsv` files. #[test] - fn deflated_catalogs_match_their_files() { + fn packed_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_eq!(unpack(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"); @@ -732,6 +778,18 @@ mod tests { assert_eq!(tr(Lang(info), "Layer"), "Layer", "falls back to English"); } + /// Packed lines unpack to their keys; references to keys that don't exist are an error, not a + /// panic. + #[test] + fn packed_lines_unpack_and_bad_references_are_errors() { + let keys = ["\tOpen", "@id\tfile.open", "\tClose"]; + let packed = "# c\n\x01Ouvrir\n\x02-1\tOuvrir\n\x02+1\tFermer\n\x03\x01x\n"; + assert_eq!(expand(packed, &keys).unwrap(), "# c\n\tOpen\tOuvrir\n\tOpen\tOuvrir\n\tClose\tFermer\n\x01x\n"); + for bad in ["\x02-1\tx", "\x02 3\tx", "\x023", "\x029223372036854775807\tx", "\x02-9223372036854775808\tx", "\x01a\n\x01b\n\x01c\n\x01d"] { + assert!(expand(bad, &keys).is_err(), "{bad:?}"); + } + } + /// Every bundled catalog is well-formed and consistent with its sources. #[test] fn bundled_catalogs_are_consistent() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 08e3464d..2042e1e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,6 +71,7 @@ pub mod distort_ui; pub mod doc_props_ui; pub mod dock; pub mod duplicate_layer_ui; +pub mod egui_fonts; pub mod enable_rules; pub mod eraser_ui; pub mod export_dialog; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0719c5ce..bee0104b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -726,7 +726,8 @@ pub fn install_fonts(ctx: &egui::Context) { /// [`install_fonts`] with the CJK and other-script 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(); + // egui's default fonts stay last in the stack, for symbols and emoji Inter lacks. + let mut fonts = crate::egui_fonts::definitions(); // 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 { diff --git a/packaging/web/README.md b/packaging/web/README.md index 4079c838..5ca375f6 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -7,7 +7,7 @@ static site in `photocraft-web-/`: |---|---| | `index.html` | The page. It loads everything through relative URLs. | | `photocraft-web-.js` | wasm-bindgen glue (generated, ES module) | -| `photocraft-web-_bg.wasm` | The app: about 19 MiB raw, 8 MiB with gzip, 5.6 MiB with Brotli (see [Sizes](#sizes)) | +| `photocraft-web-_bg.wasm` | The app: about 23 MiB raw, 10.5 MiB with gzip (see [Sizes](#sizes)) | | `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | | `fonts/` (optional) | Fonts loaded on demand, listed in `fonts/manifest.txt` (see [Fonts](#fonts)) | @@ -74,19 +74,20 @@ To verify the browser deployment, open the page, open an image and save/export i ## Sizes -Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11): +Measured on 2026-10-12 (0.8.0, `packaging/web/package.sh`; gzip `-9`): -| File | Raw | gzip | Brotli | -|---|---|---|---| -| `photocraft-web-_bg.wasm` | 19,680,726 bytes (18.8 MiB) | 8,177,260 (7.8 MiB) | 5,868,731 (5.6 MiB) | -| `photocraft-web-.js` | about 160 KB | about 23 KB | about 20 KB | +| File | Raw | gzip | +|---|---|---| +| `photocraft-web-_bg.wasm` | 24,167,186 bytes (23.0 MiB) | 10,998,053 (10.5 MiB) | +| `photocraft-web-.js` | about 170 KB | about 26 KB | Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over **25 MiB** (26,214,400 bytes). The wasm fits, and `packaging/web/package.sh` fails the build if it ever grows past 24 MiB, so a release can't ship a file a common host refuses. (Releases up to 0.2.0 shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) The size comes from the -`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel crates kept at full -speed) plus `wasm-opt -Oz`. +`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel and file-decoding +crates kept at speed) plus `wasm-opt -Oz`, and from storing the bundled fonts (PhotoCraft's and +egui's) and the translation catalogs compressed, unpacked on first use. ## Any path works diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 1cb64f17..70e1d81b 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -94,7 +94,7 @@ fn registered_languages(source: &str) -> Result, String> { let catalog = if code == "en" { None } else { - // `catalog!("xx")` embeds `xx.tsv` (deflated by ui-egui's build.rs). + // `catalog!("xx")` embeds `xx.tsv` (packed by ui-egui's build.rs). let marker = "catalog!(\""; let start = block.find(marker).ok_or_else(|| format!("language `{code}` has no catalog! entry"))? + marker.len(); let rest = block.get(start..).ok_or_else(|| format!("language `{code}` has a malformed catalog path"))?;