mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-30 04:47:09 -06:00
The "web build and wasm size gate" job failed on main and every PR: the release `_bg.wasm` measured 25,379,677 bytes at8d49ecf5, over the 25,165,824-byte limit in packaging/web/package.sh. - egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font) were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature. The app now builds egui without it; ui-egui's build.rs deflates the same files from the `epaint_default_fonts` crate (a build-dependency, so no font files are committed) and `egui_fonts.rs` rebuilds egui's exact default stack from them on first use. Test builds keep the feature as the oracle: the rebuilt definitions equal egui's byte for byte. - Translation catalogs: every catalog repeated the same English sources. build.rs now stores the `context<TAB>source` keys once and each catalog as references plus translations, losslessly (`text()` still returns each .tsv byte for byte; malformed lines are kept as they are). - wasm-release profile: opt-level "z" for PhotoCraft's own crates. Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and the pixel crates at 3, so decoding and filters keep their speed. Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz): upstream main8d49ecf525,379,677 bytes this change 24,167,186 bytes (-1,212,491) gate 25,165,824 bytes (998,638 headroom) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
10 KiB
Rust
226 lines
10 KiB
Rust
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
|
//! standards/contributors.md). Nothing is read at run time.
|
|
//!
|
|
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
|
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
|
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
|
//! JSON says the entry was verified (`people_entry`).
|
|
//!
|
|
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
|
//!
|
|
//! 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 _;
|
|
|
|
use serde_json::Value;
|
|
|
|
fn main() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
|
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| serde_json::from_str::<Value>(t).map_err(|e| e.to_string()))
|
|
.unwrap_or(Value::Null);
|
|
let people =
|
|
read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string())).unwrap_or_default();
|
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
|
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
|
println!("cargo::warning=writing {}: {e}", out.display());
|
|
}
|
|
pack_catalogs();
|
|
deflate_egui_fonts();
|
|
}
|
|
|
|
/// 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 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 (name, bytes) in fonts {
|
|
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
|
|
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 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());
|
|
}
|
|
}
|
|
|
|
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
|
println!("cargo::rerun-if-changed={}", path.display());
|
|
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
|
if let Err(e) = &r {
|
|
println!("cargo::warning={}: {e}; {fallback}", path.display());
|
|
}
|
|
r.ok()
|
|
}
|
|
|
|
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
|
fn name(v: Option<&str>) -> String {
|
|
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
|
Some(x) => format!("Some({x:?})"),
|
|
None => "None".into(),
|
|
}
|
|
}
|
|
|
|
fn s(v: &Value, k: &str) -> String {
|
|
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
|
}
|
|
|
|
fn n(v: &Value, k: &str) -> u64 {
|
|
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
|
}
|
|
|
|
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
|
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
|
}
|
|
|
|
fn generate(json: &Value, people: &toml::Table) -> String {
|
|
let mut o = String::new();
|
|
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
|
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
|
for c in rows(json, "contributors") {
|
|
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
|
if login.is_empty() {
|
|
continue;
|
|
}
|
|
// Names only from a verified, self-chosen people.toml entry.
|
|
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
|
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
|
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
|
let display =
|
|
field("display_name").filter(|s| !s.trim().is_empty()).or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
|
let _ = writeln!(
|
|
o,
|
|
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
|
name(display),
|
|
name(field("real_name")),
|
|
n(&c, "prs"),
|
|
n(&c, "commits"),
|
|
n(&c, "lines_added"),
|
|
n(&c, "lines_deleted"),
|
|
n(&c, "binary_added"),
|
|
n(&c, "binary_deleted"),
|
|
s(&c, "first_commit"),
|
|
s(&c, "last_commit"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
|
for m in rows(json, "models") {
|
|
let _ = writeln!(
|
|
o,
|
|
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
|
s(&m, "company"),
|
|
s(&m, "model"),
|
|
s(&m, "version"),
|
|
n(&m, "commits"),
|
|
n(&m, "lines_added"),
|
|
n(&m, "lines_deleted"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
o
|
|
}
|