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:
Claude committed 2026-10-12 12:56:54 -04:00
commit 7f9c4394c1
12 files changed
+308 -57

No files matched your search

Generated
+1
View File
@@ -4575,6 +4575,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"epaint_default_fonts",
"flate2",
"log",
"photocraft-algo",
+19 -19
View File
@@ -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]
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 }
+7 -2
View File
@@ -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
View File
@@ -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());
}
}
+91
View File
@@ -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());
}
}
+64 -6
View File
@@ -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() {
+1
View File
@@ -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;
+2 -1
View File
@@ -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> {
+9 -8
View File
@@ -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
+1 -1
View File
@@ -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"))?;