mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
Merge #4253: wasm back under the 24 MiB gate
Combined with #4147's opt-level "z" list. Conflict in Cargo.toml's
wasm-release profile: kept #4147's photocraft-psd = "z" and dropped
#4253's "s" entry for it (one table per package). Measured with trunk
--release: main 184c226a is 25,235,857 bytes (over the 25,165,824 gate);
with this merge, 24,161,781.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7f9c4394c1
12 files changed
+308
-57
No files matched your search
Generated
+1
@@ -4575,6 +4575,7 @@ dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
"flate2",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
|
||||
+19
-19
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
+111
-18
@@ -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/<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.
|
||||
//! 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 (`context<TAB>source<TAB>translation`, 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 `context<TAB>source`, one per line, in order of first
|
||||
/// appearance (the largest catalog first).
|
||||
/// - `<code>.tsv.deflate`: the catalog, each entry line replaced by `\x01<translation>` when its key
|
||||
/// follows the previous entry's in `keys`, else `\x02<offset><TAB><translation>` (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<Vec<u8>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Hack-Regular")));
|
||||
static NOTO_EMOJI: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular")));
|
||||
static UBUNTU_LIGHT: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light")));
|
||||
static EMOJI_ICON: LazyLock<Vec<u8>> = 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<u8> {
|
||||
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<String> { 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Every catalog entry's `context<TAB>source`, 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<String, String> {
|
||||
static KEY_TEXT: OnceLock<Result<String, String>> = 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<String, String> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
|
||||
@@ -7,7 +7,7 @@ static site in `photocraft-web-<version>/`:
|
||||
|---|---|
|
||||
| `index.html` | The page. It loads everything through relative URLs. |
|
||||
| `photocraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
|
||||
| `photocraft-web-<hash>_bg.wasm` | The app: about 19 MiB raw, 8 MiB with gzip, 5.6 MiB with Brotli (see [Sizes](#sizes)) |
|
||||
| `photocraft-web-<hash>_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-<hash>_bg.wasm` | 19,680,726 bytes (18.8 MiB) | 8,177,260 (7.8 MiB) | 5,868,731 (5.6 MiB) |
|
||||
| `photocraft-web-<hash>.js` | about 160 KB | about 23 KB | about 20 KB |
|
||||
| File | Raw | gzip |
|
||||
|---|---|---|
|
||||
| `photocraft-web-<hash>_bg.wasm` | 24,167,186 bytes (23.0 MiB) | 10,998,053 (10.5 MiB) |
|
||||
| `photocraft-web-<hash>.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
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ fn registered_languages(source: &str) -> Result<Vec<Language>, 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"))?;
|
||||
|
||||
Reference in new issue
Block a user