Merge current main fixes and validate craft-print rollout

This commit is contained in:
skylar taylor-barrick 2026-10-12 19:39:05 +02:00
commit a301e31864
51 changed files with 9553 additions and 164 deletions

View File

@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

View File

@ -19,7 +19,9 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |

63
Cargo.lock generated
View File

@ -1751,6 +1751,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"num-complex",
"pulp",
"rayon-core",
"smallvec",
"zune-inflate",
]
@ -2339,9 +2340,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@ -4190,7 +4191,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"arboard",
"async-io",
@ -4232,7 +4233,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"crc32fast",
"flate2",
@ -4242,7 +4243,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -4255,7 +4256,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"base64",
"cap-std",
@ -4277,7 +4278,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@ -4291,7 +4292,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"moxcms",
"rayon",
@ -4299,7 +4300,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"exr",
"flate2",
@ -4320,7 +4321,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-cms",
"serde",
@ -4328,7 +4329,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"criterion",
"photocraft-color",
@ -4343,7 +4344,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -4354,7 +4355,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"blake3",
"craft-print",
@ -4389,7 +4390,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"blake3",
"crc32fast",
@ -4409,14 +4410,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"half",
"log",
@ -4433,14 +4434,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"blake3",
"crc32fast",
@ -4469,14 +4470,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -4487,7 +4488,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -4502,7 +4503,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"flate2",
"photocraft-psd",
@ -4513,7 +4514,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -4523,7 +4524,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"flate2",
"image",
@ -4538,7 +4539,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@ -4550,7 +4551,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"memory-stats",
"photocraft-color",
@ -4561,7 +4562,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"flate2",
"parley",
@ -4575,7 +4576,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"base64",
"craft-ui",
@ -4614,7 +4615,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@ -4625,7 +4626,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"eframe",
"egui",
@ -7458,7 +7459,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"serde",
"serde_json",

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.8.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -110,6 +110,31 @@ opt-level = "z"
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
# 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
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
opt-level = "z"
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Keep the browser UI toolkit and window shell compact. Native profiles and the
# speed-oriented pixel-processing kernels below retain their settings.
[profile.wasm-release.package.egui]
opt-level = "z"
[profile.wasm-release.package.eframe]
opt-level = "z"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]

View File

@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@ -308,7 +312,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
@ -341,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
warn_all(err, &ws);
}
Ok(())

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

View File

@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
thiserror.workspace = true
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
png = "0.18"
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
exr = { version = "1.73", default-features = false }
half = "2"
serde_json.workspace = true
@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
exr = { version = "1.73", default-features = false, features = ["rayon"] }
[dev-dependencies]
proptest.workspace = true

View File

@ -16,7 +16,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.1"
heic-rs = "=0.1.4"
[profile.release]
debug = 1

View File

@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
/// after checking every part's declared size against the limits, before any pixel allocation.
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
let mut total: u64 = 0;
@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
best
}
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
/// return the requested part's layer.
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let image =
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
/// is asked about the headers in file order and the first `Ok` wins, so every header before
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
/// then decompressed and only its planes allocated; the other parts are skipped unread.
struct OnlyPart<C> {
inner: C,
index: usize,
seen: std::cell::Cell<usize>,
}
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
type Reader = C::Reader;
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
let position = self.seen.get();
self.seen.set(position.saturating_add(1));
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
}
}
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
}
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {

View File

@ -11,7 +11,6 @@ use std::io::Cursor;
use exr::meta::MetaData;
use exr::meta::attribute::AttributeValue;
use exr::prelude::{ReadChannels, ReadLayers, read};
use crate::Format;
use crate::error::CodecError;
@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
.iter()
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
// Also caps all parts' declared sizes together, like every other flat decode.
let meta = super::exr::read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
}
@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
limits.check_bytes(w32, h32, per_pixel)?;
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|e| match e {
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
e => CodecError::malformed(F, e),
},
)?;
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];

View File

@ -197,3 +197,32 @@ fn parts_are_capped_together() {
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
assert!(e.contains("across 3 parts"), "{e}");
}
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
/// inflating it fails.
fn file_with_corrupt_first_part() -> Vec<u8> {
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
let mut cursor = std::io::Cursor::new(&bytes[..]);
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
let table = cursor.position() as usize;
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
for i in 0..4 {
let at = table + i * 8;
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
bytes[offset + 12..offset + 12 + size].fill(0xA5);
}
bytes
}
#[test]
fn decoding_one_part_does_not_decode_the_others() {
let bytes = file_with_corrupt_first_part();
// Part 0 is unreadable ...
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
// ... but asking for part 1 must not touch it.
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}

View File

@ -1,3 +1,6 @@
mod common;
use common::tiffgen::*;
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
fn classic_white_is_zero() -> Vec<u8> {
@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
assert_eq!(image.data(), &upright, "orientation {orientation}");
}
}
fn with_compression(mut dir: Dir, code: u64) -> Dir {
for entry in &mut dir.entries {
if entry.0 == 259 {
entry.2 = vec![V::N(code)];
}
}
dir
}
#[test]
fn jpeg_compressed_tiff_decodes() {
// A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7).
let mut jpeg = Vec::new();
let gray = vec![100u8; 16 * 16];
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
dir.chunks = vec![jpeg];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode");
assert_eq!(image.dimensions(), (16, 16));
assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4));
}
#[test]
fn ccitt_group4_tiff_decodes() {
// 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB.
let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001";
let mut data = Vec::new();
for chunk in bits.as_bytes().chunks(8) {
let mut byte = 0u8;
for (i, c) in chunk.iter().enumerate() {
if *c == b'1' {
byte |= 0x80 >> i;
}
}
data.push(byte);
}
let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4);
dir.chunks = vec![data];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a CCITT G4 TIFF should decode");
assert_eq!(image.dimensions(), (8, 3));
let d = image.data();
assert_eq!(d.len(), 24, "{d:?}");
assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}");
assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}");
assert_ne!(d[0], d[8], "{d:?}");
assert_eq!(d[0], 255, "white row of a WhiteIsZero image");
}

View File

@ -2,3 +2,4 @@ target/
corpus/
artifacts/
Cargo.lock
coverage/

View File

@ -9,11 +9,11 @@ rust-version.workspace = true
[dependencies]
# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files;
# moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests).
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
[lints]
workspace = true

View File

@ -5,8 +5,8 @@
//! stores whole-document snapshots per transaction, which is simple, obviously correct, and the
//! same approach Photoshop's History panel exposes to users (one state per step).
//!
//! Memory is bounded by `max_states` plus an approximate byte budget. Only tiles *not shared*
//! with the current document count against it.
//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's
//! tiles and tiles only history holds count against it, including selection masks.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@ -248,7 +248,7 @@ impl History {
}
}
/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels) not already in `seen`.
/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`.
fn tile_bytes(doc: &Document, seen: &mut HashSet<usize>) -> usize {
let mut n = 0usize;
tiles::surfaces(doc, |surface| {
@ -408,6 +408,85 @@ mod tests {
assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4);
}
#[test]
fn selection_tiles_count_current_and_history_without_double_counting() {
use photocraft_doc::{PixelFormat, Surface};
for depth in SampleType::ALL {
let mut doc = Document::new("selection", Size::new(300, 64), ColorMode::Rgb, depth);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
selection.write_pixel(1, 1, &[1.0]);
selection.write_pixel(257, 1, &[1.0]);
doc.selection = Some(selection);
let mut cur = Arc::new(doc);
let tile = match depth {
SampleType::U8 => 65_536,
SampleType::U16 => 131_072,
SampleType::F32 => 262_144,
};
let mut h = History { max_bytes: usize::MAX, ..History::default() };
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "current selection counts without any history");
assert_eq!(h.unique_bytes(&cur), 0);
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into());
assert_eq!(h.pixel_bytes(&cur), 2 * tile, "the shared selection tiles count once");
edit(&mut h, &mut cur, "Selection", |doc| {
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[0.5]);
});
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "only the changed selection tile is copied");
assert_eq!(h.unique_bytes(&cur), tile);
h.trim(&cur);
assert_counter(&h, &cur);
let selected_value = cur.selection.as_ref().unwrap().sample_channel(1, 1, 0);
edit(&mut h, &mut cur, "Deselect", |doc| doc.selection = None);
assert_eq!(h.pixel_bytes(&cur), 3 * tile, "deselecting leaves the previous masks in history");
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.selection.as_ref().unwrap().sample_channel(1, 1, 0), selected_value);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), tile);
assert_counter(&h, &cur);
// Coalescing can change the current mask without recording another state.
Arc::make_mut(&mut cur).selection.as_mut().unwrap().write_pixel(257, 1, &[0.25]);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_eq!(h.trim(&cur), 0);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert!(cur.selection.is_none());
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_counter(&h, &cur);
}
}
#[test]
fn selection_byte_budget_drops_old_masks_and_keeps_the_last_undo() {
use photocraft_doc::{PixelFormat, Surface};
for depth in SampleType::ALL {
let mut doc = Document::new("selection", Size::new(64, 64), ColorMode::Rgb, depth);
doc.selection = Some(Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)));
let mut cur = Arc::new(doc);
let tile = 256 * 256 * depth.bytes();
let mut h = History::default();
for i in 1..=6 {
edit(&mut h, &mut cur, "Selection", |doc| {
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0]);
});
}
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
h.max_bytes = 3 * tile;
assert_eq!(h.trim(&cur), 4);
assert_eq!(h.past_len(), 2);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), 2 * tile);
assert_counter(&h, &cur);
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 1);
assert_eq!(h.past_len(), 1, "the most recent undo survives even below one mask's size");
assert_eq!(h.pixel_bytes(&cur), 2 * tile);
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
}
}
fn assert_counter(h: &History, current: &Document) {
let counter = h.tiles.as_ref().expect("budget initializes the counter");
let total = tile_bytes(current, &mut HashSet::new()).saturating_add(h.unique_bytes(current));
@ -506,11 +585,13 @@ mod tests {
fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut cur = Arc::new(Document::with_background("h", Size::new(300, 64), ColorMode::Rgb, depth, Color::WHITE));
Arc::make_mut(&mut cur).selection = Some(photocraft_doc::Surface::new(photocraft_doc::PixelFormat::GRAY8));
let mut h = History::new(4);
h.max_bytes = usize::MAX;
for i in 0..8 {
edit(&mut h, &mut cur, "Paint", |doc| {
doc.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0, 0.0, 0.0, 1.0]);
doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0]);
});
h.trim(&cur);
if i >= 1 {
@ -547,6 +628,7 @@ mod tests {
gray.write_pixel(1, 1, &[1.0]);
doc.layers[0].mask = Some(LayerMask { surface: gray.clone(), ..LayerMask::reveal_all() });
doc.channels.push(AlphaChannel::new("alpha", gray.clone()));
doc.selection = Some(gray.clone());
doc.quick_mask = Some(AlphaChannel::new("quick", gray));
doc.layers.push(doc.layers[0].clone());
let mut cur = Arc::new(doc);

View File

@ -30,6 +30,9 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) {
for channel in doc.channels.iter().chain(&doc.quick_mask) {
visit(&channel.surface);
}
if let Some(selection) = &doc.selection {
visit(selection);
}
}
impl Tiles {

View File

@ -2,3 +2,4 @@ target
corpus
artifacts
coverage
Cargo.lock

5
crates/raw/fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock

View File

@ -134,19 +134,33 @@ fn dialog_mem(kind: &str) -> egui::Id {
egui::Id::new(("adjust-dialog", kind))
}
/// Whether holding Alt turns the dialog's Cancel into Reset. Measured in Photoshop 25.4 for Color
/// Balance; other kinds keep Cancel until checked against Photoshop.
/// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind).
pub fn resets(fields: &Map<String, Value>) -> bool {
fields.get("__adjust").and_then(Value::as_str) == Some("colorBalance")
owns(fields)
}
/// Alt+Cancel (Reset): back to the defaults Photoshop's Reset restores, dialog stays open.
pub fn reset(ctx: &egui::Context, fields: &mut Map<String, Value>) {
if !resets(fields) {
/// Alt+Cancel (Reset): engine defaults for the kind; Color Balance keeps Preserve Luminosity and
/// shows Midtones (Photoshop 25.4). Dialog stays open.
pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
if !owns(fields) {
return;
}
let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string();
let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode);
// Color Balance: Photoshop keeps the user's Preserve Luminosity across Reset.
let preserve = if kind == "colorBalance" { fields.get("preserveLuminosity").cloned() } else { None };
let Ok(defaults) = photocraft_engine::adjust_params::default_for(&kind, mode) else { return };
let values = photocraft_engine::adjust_params::to_params(&defaults);
// Replace parameter keys; keep private `__` chrome (preview, command, …).
fields.retain(|k, _| k.starts_with("__"));
if let Value::Object(v) = values {
fields.extend(v);
}
if let Some(p) = preserve {
fields.insert("preserveLuminosity".into(), p);
}
let mut values = crate::filter_dialog::params_of(fields);
adjust_editors::reset_color_balance(ctx, dialog_mem("colorBalance"), &mut values);
adjust_editors::reset_adjustment_editor(ctx, &kind, dialog_mem(&kind), &mut values);
if let Value::Object(v) = values {
fields.extend(v);
}
@ -390,16 +404,29 @@ mod tests {
assert!(h.state().ui.dialogs.is_empty(), "Cancel closes");
}
/// Alt+Cancel is Reset only where it was checked against Photoshop.
/// Every adjustment kind opts into Alt+Reset; Levels restores engine defaults and stays open.
#[test]
fn other_adjustment_dialogs_keep_cancel_under_alt() {
fn levels_alt_reset_restores_defaults() {
use egui_kittest::kittest::Queryable;
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
open(h.state_mut(), "image.adjustments.levels").unwrap();
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
h.run_steps(3);
let id = open(h.state_mut(), "image.adjustments.levels").unwrap();
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30));
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("gamma".into(), json!(1.3));
h.run_steps(2);
assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none());
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
h.run_steps(2);
let reset = h.get_by_label("Reset").rect();
assert!(h.query_by_label("Cancel").is_none());
let click =
|pressed| egui::Event::PointerButton { pos: reset.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT };
input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(reset.center()), click(true), click(false)]);
assert!(h.state().ui.dialogs.iter().any(|d| d.id == id), "Reset keeps the dialog open");
let defaults =
photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap());
assert_eq!(field(&h, id, "inBlack"), defaults["inBlack"]);
assert_eq!(field(&h, id, "gamma"), defaults["gamma"]);
}
fn sample_params(kind: &str) -> Value {

View File

@ -1019,6 +1019,14 @@ pub fn reset_color_balance(ctx: &egui::Context, mem: egui::Id, v: &mut Value) {
ctx.data_mut(|d| d.insert_temp(color_balance_tone_id(mem), 1usize));
}
/// Kind-specific editor memory after Alt+Reset (or Properties Reset to defaults). Color Balance
/// shows Midtones; other kinds have no dialog-local temp to clear yet.
pub fn reset_adjustment_editor(ctx: &egui::Context, kind: &str, mem: egui::Id, v: &mut Value) {
if kind == "colorBalance" {
reset_color_balance(ctx, mem, v);
}
}
fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let tone_id = color_balance_tone_id(cx.mem);
let mut tone_ix: usize = ui.data(|d| d.get_temp(tone_id)).unwrap_or(1);

View File

@ -298,6 +298,15 @@ pub fn owns(f: &Map<String, Value>) -> bool {
f.contains_key("__colorPicker")
}
/// Alt+Cancel (Reset): restore the colour from when the picker opened (same as clicking Current).
pub fn reset(f: &mut Map<String, Value>) {
if !owns(f) {
return;
}
let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]);
set_rgb(f, orig, Keep::Nothing);
}
/// The Color Picker, when it is the top dialog (another dialog opened over it takes the input).
pub fn top(app: &PhotocraftApp) -> Option<u64> {
app.ui.dialogs.last().filter(|d| owns(&d.fields)).map(|d| d.id)
@ -833,6 +842,18 @@ mod tests {
assert_eq!(hex([h.state().session.tools.foreground[0], h.state().session.tools.foreground[1], h.state().session.tools.foreground[2]]), "#ff8800");
}
#[test]
fn alt_reset_restores_orig_color() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
let id = open(&mut app, "foreground");
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
f.insert("color".into(), json!("#ff0000"));
reset(f);
assert_eq!(f["color"], json!(hex([0.2, 0.4, 0.6])));
assert_eq!(f["__orig"], json!(hex([0.2, 0.4, 0.6])));
}
#[test]
fn lab_and_cmyk_fields_take_multi_digit_values() {
let base = parse_hex("#336699").unwrap();

View File

@ -0,0 +1,93 @@
//! Alt+Cancel → Reset for command dialogs that opt in.
//!
//! Holding Alt in the dialog chrome relabels Cancel as Reset and, on click, restores the dialog's
//! fields without closing it (Photoshop). Each marker implements its own `reset`; this module only
//! dispatches. Esc stays Cancel (`Modifiers::NONE` in the frame). Opt out with `"__altReset": false`.
use serde_json::{Map, Value};
use crate::PhotocraftApp;
/// Whether holding Alt turns Cancel into Reset for this dialog's fields.
pub fn supported(fields: &Map<String, Value>) -> bool {
if fields.get("__altReset") == Some(&Value::Bool(false)) {
return false;
}
match_arm(fields).is_some()
}
/// Apply Reset in place. No-op when the dialog has not opted in.
pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
if fields.get("__altReset") == Some(&Value::Bool(false)) {
return;
}
// Dispatch order matches `dialogs::show` so `__adjust` wins over `__filter`.
if crate::guide_layout_ui::owns(fields) {
crate::guide_layout_ui::reset(app, fields);
} else if crate::fill_ui::owns(fields) {
crate::fill_ui::reset(app, fields);
} else if crate::variables_ui::owns(fields) {
crate::variables_ui::reset(fields);
} else if crate::color_picker_ui::owns(fields) {
crate::color_picker_ui::reset(fields);
} else if fields.contains_key("__sizing") {
crate::sizing::reset(fields);
} else if crate::adjust_dialog::owns(fields) {
crate::adjust_dialog::reset(app, ctx, fields);
} else if fields.contains_key("__filter") {
crate::filter_dialog::reset_fields(app, fields);
} else if fields.contains_key("__form") {
crate::view_cmds::reset_form(fields);
}
}
/// First matching opt-in arm, if any (same order as [`apply`]).
fn match_arm(fields: &Map<String, Value>) -> Option<&'static str> {
if crate::guide_layout_ui::owns(fields) {
Some("guideLayout")
} else if crate::fill_ui::owns(fields) {
Some("fill")
} else if crate::variables_ui::owns(fields) && fields.contains_key("__variablesOrig") {
Some("variables")
} else if crate::color_picker_ui::owns(fields) {
Some("colorPicker")
} else if fields.contains_key("__sizing") {
Some("sizing")
} else if crate::adjust_dialog::owns(fields) {
Some("adjust")
} else if fields.contains_key("__filter") {
Some("filter")
} else if fields.contains_key("__form") && fields.contains_key("__formOrig") {
Some("form")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn alt_reset_false_opts_out() {
let mut f = Map::new();
f.insert("__adjust".into(), json!("levels"));
f.insert("__altReset".into(), json!(false));
assert!(!supported(&f));
}
#[test]
fn adjust_marker_opts_in() {
let mut f = Map::new();
f.insert("__adjust".into(), json!("levels"));
assert!(supported(&f));
}
#[test]
fn bare_confirm_does_not_opt_in() {
let mut f = Map::new();
f.insert("__command".into(), json!("edit.purge"));
assert!(!supported(&f));
}
}

View File

@ -310,7 +310,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let can_ok = crate::crop_presets::can_confirm(app, &fields);
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
let reset = ui.input(|i| i.modifiers.alt) && crate::adjust_dialog::resets(&fields);
// Dialogs opt in via `dialog_alt_reset::supported` (per marker).
let reset = ui.input(|i| i.modifiers.alt) && crate::dialog_alt_reset::supported(&fields);
let cancel_label = if reset {
tl!("Reset")
} else if d.kind == DialogKind::NewDocument {
@ -337,7 +338,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
dialog_buttons(ui, &[ok, cancel])
};
match clicked {
Some(ButtonRole::Cancel) if reset => crate::adjust_dialog::reset(ui.ctx(), &mut fields),
Some(ButtonRole::Cancel) if reset => crate::dialog_alt_reset::apply(app, ui.ctx(), &mut fields),
Some(ButtonRole::Cancel) => outcome = Some(false),
Some(ButtonRole::Apply) => apply_requested = true,
Some(_) => outcome = Some(true),

View File

@ -719,7 +719,9 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() {
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(written.borrow().len(), 2);
assert_eq!(written.borrow()[0], "/out/boards.jpg");
assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow());
// The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows).
let second = written.borrow()[1].replace('\\', "/");
assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow());
let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::<Vec<_>>();
got.sort_unstable();
assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %");

View File

@ -53,8 +53,8 @@ fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool {
}
}
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
/// Photoshop's Fill defaults (no last-used prefs). Used by open and Alt+Reset.
pub fn default_fields(app: &PhotocraftApp) -> Map<String, Value> {
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
@ -68,6 +68,12 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
f.insert("mode".into(), json!("normal"));
f.insert("opacity".into(), json!(100.0));
f.insert("preserveTransparency".into(), json!(false));
f
}
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
let mut f = default_fields(app);
if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) {
for k in KEYS {
if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) {
@ -78,6 +84,19 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
f
}
/// Alt+Cancel (Reset): Photoshop Fill defaults, not last-used prefs. Dialog stays open.
pub fn reset(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
if !owns(fields) {
return;
}
let defaults = default_fields(app);
for k in KEYS {
if let Some(v) = defaults.get(k) {
fields.insert(k.into(), v.clone());
}
}
}
/// Open the Fill dialog; returns its id.
pub fn open(app: &mut PhotocraftApp) -> u64 {
let f = fields(app);

View File

@ -74,6 +74,22 @@ fn mode_opacity_and_preserve_transparency() {
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]));
}
#[test]
fn alt_reset_restores_photoshop_defaults_not_last_used() {
let mut app = app();
// Remember a non-default choice, then open (which restores it) and Reset.
fill(&mut app, json!({"contents": "white", "opacity": 40})).unwrap();
let id = open(&mut app);
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64(), Some(40.0));
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
reset(&app, &mut fields);
app.ui.dialog_mut(id).unwrap().fields = fields;
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!(f["opacity"].as_f64(), Some(100.0));
assert_eq!(f["contents"], json!("foreground"));
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
}
#[test]
fn choices_persist_across_restarts() {
let mut app = app();

View File

@ -327,6 +327,70 @@ pub(crate) fn remember(app: &mut PhotocraftApp, command: &str, fields: &Map<Stri
}
}
/// Schema-default values for one param of `command` (open and Alt+Reset share this).
fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option<Value> {
Some(match &p.kind {
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
Kind::Range { default, .. } => json!(default),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Text => json!(""),
Kind::Document => json!(-1),
// Identity kernel: 1 in the centre.
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
Kind::Color(None) => return None,
// Colour inputs come from the current swatches, so the proxy preview matches the result.
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
Kind::Json if p.key == "background" => json!(app.session.tools.background),
Kind::Json => return None,
})
}
/// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs.
fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map<String, Value>) {
if command == "image.rotation.arbitrary" {
straighten_defaults(app, fields);
}
if command == "type.warpText" {
warp_defaults(app, fields);
}
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
if command == "edit.definePattern" {
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
}
if command == "layer.layerStyle.globalLight" {
global_light_defaults(app, fields);
}
if command == "view.proofSetup" {
proof_setup_defaults(app, fields);
}
}
/// Alt+Cancel (Reset): schema defaults + command hooks, without last-used remembered prefs.
pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
if !fields.contains_key("__filter") || fields.contains_key("__adjust") {
return;
}
let Some(command) = fields.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
let spec =
fields.get("__spec").and_then(Value::as_str).map(str::to_owned).or_else(|| photocraft_engine::commands::find(&command).map(|c| c.params.to_owned()));
let Some(spec) = spec else { return };
// Drop user params; keep private `__` chrome (and fixed keys outside the schema for `__spec`).
let schema_keys: std::collections::HashSet<String> = parse_spec(&spec).into_iter().map(|p| p.key).collect();
fields.retain(|k, _| k.starts_with("__") || !schema_keys.contains(k));
for p in parse_spec(&spec) {
if let Some(v) = default_value(app, &command, &p) {
fields.insert(p.key, v);
}
}
apply_command_defaults(app, &command, fields);
}
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let spec = photocraft_engine::commands::find(command)?;
let mut fields = Map::new();
@ -338,45 +402,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
fields.insert("__preview".into(), json!(true));
}
for p in parse_spec(spec.params) {
let v = match &p.kind {
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
Kind::Range { default, .. } => json!(default),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Text => json!(""),
Kind::Document => json!(-1),
// Identity kernel: 1 in the centre.
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
Kind::Color(None) => continue,
// Colour inputs come from the current swatches, so the proxy preview matches the result.
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
Kind::Json if p.key == "background" => json!(app.session.tools.background),
Kind::Json => continue,
};
fields.insert(p.key, v);
if let Some(v) = default_value(app, command, &p) {
fields.insert(p.key, v);
}
}
restore_remembered(app, command, spec.params, &mut fields);
if command == "image.rotation.arbitrary" {
straighten_defaults(app, &mut fields);
}
if command == "type.warpText" {
warp_defaults(app, &mut fields);
}
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
if command == "edit.definePattern" {
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
}
if command == "layer.layerStyle.globalLight" {
global_light_defaults(app, &mut fields);
}
if command == "view.proofSetup" {
proof_setup_defaults(app, &mut fields);
}
apply_command_defaults(app, command, &mut fields);
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
// The document picker lists every open document (params refer to them by index).
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
@ -818,6 +849,24 @@ mod tests {
assert_eq!(fields["radius"], json!(2.0));
}
#[test]
fn alt_reset_restores_schema_defaults_not_remembered() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.prefs.edit(|prefs| {
prefs.dialogs.insert("filter.blur.gaussianBlur".into(), json!({"radius": 11.0}));
});
let id = open(&mut app, "filter.blur.gaussianBlur").unwrap();
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["radius"], json!(11.0));
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
reset_fields(&app, &mut fields);
app.ui.dialog_mut(id).unwrap().fields = fields;
let f = &app.ui.dialog_mut(id).unwrap().fields;
// Registry default for gaussian blur radius is 1.
assert_eq!(f["radius"], json!(1.0));
assert!(f.contains_key("__filter"));
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
}
#[test]
fn remembering_dialogs_never_persists_paths_or_document_indices() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());

View File

@ -30,7 +30,7 @@ const CENTER_Y_FIELD: &str = "__pvCy";
/// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A
/// no-op for dialogs without a preview and for Select and Mask, which has its own view.
pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if !f.contains_key("__preview") || crate::select_mask_view::owns(f) {
if !shows(f) {
return;
}
let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
@ -145,7 +145,14 @@ const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0];
/// The vertical space the preview window takes below the parameters: the image, the zoom row and
/// their spacing. `0` when the dialog has no preview (so the caller reserves nothing).
pub(crate) fn reserved_height(f: &Map<String, Value>) -> f32 {
if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 }
if shows(f) { PREVIEW_H + 32.0 } else { 0.0 }
}
/// Whether the dialog with fields `f` gets the preview window: Filter menu dialogs with a Preview
/// checkbox, as in Photoshop. Image › Rotation › Arbitrary and the adjustment dialogs keep only
/// the on-canvas preview, and Select and Mask has its own view.
fn shows(f: &Map<String, Value>) -> bool {
f.contains_key("__preview") && !crate::select_mask_view::owns(f) && f.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("filter."))
}
/// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends.
@ -194,12 +201,29 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 {
/// A composite as monitor pixels, matching the canvas (`canvas::display_image`).
fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle {
// Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution
// previews off the proxy is the full document. The view maps through UV fractions, so a reduced
// texture still lines up.
let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side));
let reduced;
let doc = if k > 1 {
reduced = crate::proxy::proxy_document(doc, k);
&reduced
} else {
doc
};
let buf = photocraft_compose::flatten(doc);
let (display, _) = crate::canvas::canvas_display(app, doc, None);
let img = crate::canvas::display_image(display.as_deref(), &buf);
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
}
/// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits).
fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 {
let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1);
w.max(h).div_ceil(max_side).max(1)
}
/// What a cached texture was built from, so it is rebuilt only when its source changes.
#[derive(Clone, PartialEq)]
enum TexKey {
@ -238,6 +262,30 @@ mod tests {
assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM);
}
#[test]
fn only_filter_dialogs_get_the_preview_window() {
let fields = |cmd: &str| json!({"__preview": true, "__command": cmd}).as_object().cloned().unwrap();
assert!(shows(&fields("filter.blur.gaussianBlur")));
assert!(reserved_height(&fields("filter.blur.gaussianBlur")) > 0.0);
// Photoshop's Rotate Canvas and adjustment dialogs have no preview window.
assert!(!shows(&fields("image.rotation.arbitrary")));
assert_eq!(reserved_height(&fields("image.rotation.arbitrary")), 0.0);
assert!(!shows(&fields("adjust.levels")));
// No Preview checkbox or no command: no window.
assert!(!shows(json!({"__command": "filter.blur.gaussianBlur"}).as_object().unwrap()));
assert!(!shows(json!({"__preview": true}).as_object().unwrap()));
}
#[test]
fn textures_are_reduced_to_fit_the_gpu() {
assert_eq!(texture_factor(3000, 2000, 2048), 2);
assert_eq!(texture_factor(2048, 100, 2048), 1);
assert_eq!(texture_factor(9000, 9000, 2048), 5);
// A zero or absurd limit never divides by zero.
assert_eq!(texture_factor(3000, 2000, 0), 3000);
assert_eq!(texture_factor(0, 0, 2048), 1);
}
#[test]
fn the_centre_is_clamped_to_the_document() {
assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0));

View File

@ -31,6 +31,33 @@ pub fn owns(f: &Map<String, Value>) -> bool {
f.contains_key(MARK)
}
/// Alt+Cancel (Reset): factory New Guide Layout values, not the last-used remembered layout.
pub fn reset(app: &PhotocraftApp, f: &mut Map<String, Value>) {
if !owns(f) {
return;
}
let Value::Object(defaults) = crate::view_cmds::default_guide_layout() else { return };
// Drop layout params and interactive toggles; keep dialog chrome (`__command`, …).
f.retain(|k, _| {
k.starts_with("__")
&& !matches!(
k.as_str(),
"__preset" | "__columnsEnabled" | "__rowsEnabled" | "__marginEnabled" | "__previewGuides" | "__previewParams" | "__previewSource"
)
});
f.extend(defaults);
f.insert("color".into(), json!(app.session.prefs().guides_grid_and_slices.guide_color));
f.insert("target".into(), json!("document"));
f.insert("__preset".into(), json!("custom"));
f.insert("width".into(), Value::Null);
f.insert("height".into(), Value::Null);
let margin = f.get("margin").cloned().unwrap_or(json!(0));
if !margin.is_array() {
f.insert("margin".into(), json!([margin.clone(), margin.clone(), margin.clone(), margin]));
}
f.insert("__preview".into(), json!(true));
}
fn on(f: &Map<String, Value>, key: &str, fallback: bool) -> bool {
f.get(key).and_then(Value::as_bool).unwrap_or(fallback)
}

View File

@ -84,6 +84,23 @@ fn app() -> PhotocraftApp {
PhotocraftApp::new(session, crate::Services::default())
}
#[test]
fn alt_reset_restores_factory_layout() {
let mut app = app();
let id = open(&mut app);
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
fields.insert("columns".into(), json!(3));
fields.insert("gutter".into(), json!(99));
fields.insert("__preset".into(), json!("twoColumn"));
reset(&app, &mut fields);
app.ui.dialog_mut(id).unwrap().fields = fields;
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!(f["columns"], json!(8));
assert_eq!(f["gutter"], json!(20));
assert_eq!(f["__preset"], json!("custom"));
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
}
fn draw(app: &mut PhotocraftApp, id: u64) {
let mut f = app.ui.dialog_mut(id).unwrap().fields.clone();
let ctx = egui::Context::default();

View File

@ -1,6 +1,6 @@
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch and Czech UI translation catalogs are
licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT
and ../../../../LICENSE-APACHE.
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and
Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full
license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
Korean translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
@ -22,3 +22,9 @@ PhotoCraft's English labels; no proprietary translation resources were used.
Czech translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
Turkish translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
Hungarian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ pub struct LangInfo {
/// 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;
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese, Chinese and Hungarian: 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.
pub plural: fn(u64) -> usize,
@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 18] = [
pub static LANGUAGES: [LangInfo; 20] = [
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() },
@ -164,6 +164,10 @@ pub static LANGUAGES: [LangInfo; 18] = [
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
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() },
// Hungarian: a count takes the singular noun ("3 réteg"), so one plural form.
LangInfo { code: "hu", name: "Magyar", source: catalog!("hu"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here.
LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Vietnamese; `vi-VN` resolves here.
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
];
@ -418,6 +422,11 @@ mod tests {
assert_eq!(lang_from_tag("it-CH"), Lang::from_code("it"));
assert_eq!(lang_from_tag("nl_NL.UTF-8"), Lang::from_code("nl"));
assert_eq!(lang_from_tag("nl-BE"), Lang::from_code("nl"));
assert_eq!(lang_from_tag("hu_HU.UTF-8"), Lang::from_code("hu"));
assert_eq!(lang_from_tag("hu-HU"), Lang::from_code("hu"));
assert_eq!(lang_from_tag("tr_TR.UTF-8"), Lang::from_code("tr"));
assert_eq!(lang_from_tag("tr-TR"), Lang::from_code("tr"));
assert_eq!(lang_from_tag("tr"), Lang::from_code("tr"));
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
@ -513,6 +522,22 @@ mod tests {
assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos");
}
#[test]
fn hungarian_resolves_and_keeps_the_singular_after_counts() {
let hu = Lang::from_code("hu").expect("hu registered");
for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] {
assert_eq!(lang_from_tag(tag), Some(hu), "{tag}");
}
assert!(hu.complete_menus());
assert_eq!(hu.name(), "Magyar");
assert_eq!(tr(hu, "Layer"), "Réteg");
for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] {
assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected);
}
assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros");
assert_eq!(tr(hu, "Red"), "Vörös");
}
#[test]
fn brazilian_portuguese_resolves_and_pluralises() {
let pt = Lang::from_code("pt-br").expect("pt-br registered");

View File

@ -96,6 +96,8 @@ mod tests {
(vec!["pt-PT"], "pt-br"),
(vec!["it-IT", "en-US"], "it"),
(vec!["nl-BE", "fr-BE"], "nl"),
(vec!["hu-HU", "en-US"], "hu"),
(vec!["tr-TR", "en-US"], "tr"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,7 @@ pub mod crop_size;
pub mod crop_straighten;
pub mod crop_ui;
pub mod delete_layer_prompt;
pub mod dialog_alt_reset;
pub mod dialog_blend_ui;
pub mod dialogs;
pub mod direct_select;

View File

@ -46,6 +46,28 @@ pub fn is_sizing(command: &str) -> bool {
matches!(command, "image.imageSize" | "image.canvasSize")
}
/// Alt+Cancel (Reset): restore the dimensions and options from when the dialog opened.
pub fn reset(f: &mut Map<String, Value>) {
if !f.contains_key("__sizing") {
return;
}
let w = num(f, "__origW");
let h = num(f, "__origH");
f.insert("width".into(), json!(w));
f.insert("height".into(), json!(h));
if f.get("__command").and_then(Value::as_str) == Some("image.imageSize") {
f.insert("resolution".into(), json!(num(f, "__origRes")));
f.insert("__constrain".into(), json!(true));
if let Some(r) = f.get("__resample").cloned() {
f.insert("resample".into(), r);
}
} else {
f.insert("relative".into(), json!(false));
f.insert("anchor".into(), json!("center"));
f.insert("extensionColor".into(), json!("background"));
}
}
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let unit = pref_unit(app.session.prefs().units_and_rulers.rulers);
let resample = pref_resample(app.session.prefs().general.image_interpolation);
@ -443,6 +465,22 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50));
}
#[test]
fn alt_reset_restores_open_dimensions() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap();
let id = open(&mut app, "image.imageSize").unwrap();
let d = app.ui.dialog_mut(id).unwrap();
d.fields.insert("width".into(), json!(50));
d.fields.insert("height".into(), json!(25));
d.fields.insert("resolution".into(), json!(300));
d.fields.insert("__constrain".into(), json!(false));
reset(&mut d.fields);
assert_eq!((num(&d.fields, "width"), num(&d.fields, "height"), num(&d.fields, "resolution")), (200.0, 100.0, 72.0));
assert_eq!(d.fields.get("__constrain"), Some(&json!(true)));
assert!(app.ui.dialogs.iter().any(|x| x.id == id), "reset leaves the dialog open");
}
/// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens
/// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos).
#[test]

View File

@ -17,6 +17,16 @@ pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key("__variables")
}
/// Alt+Cancel (Reset): restore `__variables` from the open-time snapshot.
pub fn reset(fields: &mut Map<String, Value>) {
if !owns(fields) {
return;
}
if let Some(orig) = fields.get("__variablesOrig").cloned() {
fields.insert("__variables".into(), orig);
}
}
/// Layers available to bind, top to bottom: (id, display name, is-text).
fn layer_options(app: &PhotocraftApp) -> Vec<(u64, String, bool)> {
let Some(st) = app.session.active() else { return Vec::new() };
@ -31,6 +41,8 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
let page = if id.ends_with("dataSets") { "dataSets" } else { "define" };
let mut f = Map::new();
let state = app.session.active().map(|st| crate::variables_ui::to_state(&st.doc.variables)).unwrap_or_else(|| json!({"defs": [], "dataSets": []}));
// Snapshot at open so Alt+Reset restores the document's variables.
f.insert("__variablesOrig".into(), state.clone());
f.insert("__variables".into(), state);
f.insert("__page".into(), json!(page));
f.insert("__label".into(), json!("Variables"));
@ -40,6 +52,7 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
// No data set named: open the dialog on the Data Sets page to pick one.
let mut f = Map::new();
let state = app.session.active().map(|st| crate::variables_ui::to_state(&st.doc.variables)).unwrap_or_else(|| json!({"defs": [], "dataSets": []}));
f.insert("__variablesOrig".into(), state.clone());
f.insert("__variables".into(), state);
f.insert("__page".into(), json!("dataSets"));
f.insert("__label".into(), json!("Variables"));
@ -378,6 +391,16 @@ mod tests {
}
}
#[test]
fn alt_reset_restores_open_snapshot() {
let mut f = Map::new();
let orig = json!({"defs": [{"name": "a"}], "dataSets": []});
f.insert("__variables".into(), json!({"defs": [{"name": "edited"}], "dataSets": []}));
f.insert("__variablesOrig".into(), orig.clone());
reset(&mut f);
assert_eq!(f["__variables"], orig);
}
#[test]
fn owns_detects_the_field() {
let mut f = Map::new();

View File

@ -98,7 +98,8 @@ pub struct ViewOptions {
pub guide_layout: Value,
}
fn default_guide_layout() -> Value {
/// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions).
pub(crate) fn default_guide_layout() -> Value {
json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false})
}
@ -933,9 +934,22 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi
if let Value::Object(m) = fields {
f.extend(m);
}
// Snapshot user-facing params at open so Alt+Reset can restore them.
let orig: Map<String, Value> = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
f.insert("__formOrig".into(), Value::Object(orig));
app.ui.open_dialog(DialogKind::Command, f)
}
/// Alt+Cancel (Reset): restore the `__form` params captured at open.
pub fn reset_form(f: &mut Map<String, Value>) {
if !f.contains_key("__form") {
return;
}
let Some(Value::Object(orig)) = f.get("__formOrig").cloned() else { return };
f.retain(|k, _| k.starts_with("__"));
f.extend(orig);
}
fn label_of(key: &str) -> String {
let mut out = String::new();
for (i, c) in key.chars().enumerate() {
@ -971,6 +985,25 @@ mod label_tests {
}
}
#[cfg(test)]
mod form_reset_tests {
use super::*;
use serde_json::json;
#[test]
fn alt_reset_restores_form_orig() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let id = form(&mut app, "test.form", "Test", json!({"radius": 5.0, "on": true}), json!({}));
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
f.insert("radius".into(), json!(99.0));
f.insert("on".into(), json!(false));
reset_form(f);
assert_eq!(f["radius"], json!(5.0));
assert_eq!(f["on"], json!(true));
assert!(f.contains_key("__formOrig"));
}
}
/// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns.
pub fn form_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let choices = f.get("__choices").cloned().unwrap_or(Value::Null);

View File

@ -24,6 +24,8 @@ fn harness() -> Option<Harness> {
egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
// The queries below use English labels; don't follow the system locale.
app.session.prefs.edit(|p| p.interface.language = "en".into());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}

View File

@ -352,7 +352,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
trunk serve --release # dev server on http://127.0.0.1:8765
```
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for `photocraft-ui-egui`, "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.

View File

@ -48,7 +48,8 @@ catalog).
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the
other catalogs translate, [notes](localization-it.md)).
other catalogs translate, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every
measured key.
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
transform accessibility names, rasterization prompts, profile mismatch messages and preset
@ -79,3 +80,4 @@ engine localization or native review is complete.
| 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English |
| 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed |
| 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent |
| 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage |

View File

@ -25,6 +25,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Polski | `pl` | one / few (2–4, not 12–14) / many |
| Ελληνικά | `el` | one / other |
| Nederlands | `nl` | one / other |
| Magyar | `hu` | one form |
| Tiếng Việt | `vi` | one form |
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend

219
docs/ui-dialogs.md Normal file
View File

@ -0,0 +1,219 @@
# Dialogs
Modal and modeless parameter windows in the egui shell. They mirror Photoshop: no dimmed backdrop,
canvas pan/zoom under an open dialog, Tab cycles text and number fields only, Esc cancels, Enter
confirms when OK is enabled. Automation drives the same `fields` map as the UI (`ui.dialog.*` in
[`control-protocol.md`](control-protocol.md)).
## Architecture
| Piece | Location | Role |
|---|---|---|
| Frame, routing, OK/Cancel/Esc, stacking | `crates/ui-egui/src/dialogs.rs` | Single hub for every `UiState::dialogs` entry |
| Alt+Cancel → Reset (opt-in) | `crates/ui-egui/src/dialog_alt_reset.rs` | Relabels Cancel while Alt is held; dispatches per-marker `reset` |
| Tab-through-fields | `crates/ui-egui/src/field_tab.rs` | Photoshop-style Tab / ⇧Tab (tests in same file) |
| State | `crates/ui-egui/src/state.rs` | `Dialog`, `DialogKind`, `open_dialog` |
| Button row | `crates/ui-egui/src/widgets.rs` | `dialog_buttons`, `focus_first_field`; `number_edit` registers for Tab |
| Menu / command entry | `dialogs::open_command_dialog`, `menus.rs` | Picks a dedicated module, schema dialog, or bare confirm |
| Filters | `filter_dialog.rs` | Schema from engine params, live preview |
| Adjustments | `adjust_dialog.rs` | Shared editors with Properties; preview via `adjust_preview` |
| Generic forms | `view_cmds.rs` | `__form` grids for simple command params |
| Dedicated bodies | See **Command routing** below | `body` + usually `open` / `confirm` |
**Routing.** Non-command kinds use `DialogKind` alone. `DialogKind::Command` bodies are chosen by
marker keys on `fields` (`owns()` or `contains_key("__export")`, etc.); **first match in
`dialogs::show` wins**, so new markers must not collide with an earlier arm. `show`, `confirm`, and
`cancel` share the same dispatch table—wire all three when adding a dialog.
**Field contract.** `Dialog.fields` is a JSON map: command parameters agents read and write, plus
private keys prefixed with `__`. Never use `__` for user-facing or engine params.
### `DialogKind` (top-level stack)
| Kind | Body module | Typical entry |
|---|---|---|
| `NewDocument` | `new_doc_ui.rs` | File › New, `file.new` with no size params |
| `About` | `dialogs.rs` (About / Contributors / Models tabs) | Help › About PhotoCraft; `systemInfo: true` → System Info variant |
| `Command` | Routed—see below | Most menu commands with parameters |
| `LayerStyle` | `layer_style.rs` | Layer › Layer Style… |
| `Error` | inline in `dialogs.rs` | `message` field only (e.g. move lock, locked transform) |
### Command routing (`DialogKind::Command`)
Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows before adding a marker).
| Match | Module | Role |
|---|---|---|
| `__guideLayout` | `guide_layout_ui.rs` | View › New Guide Layout… |
| `__fill` | `fill_ui.rs` | Edit › Fill… |
| `__patternFill` | `pattern_fill_ui.rs` | Pattern Fill layer / New Fill Layer › Pattern… |
| `__stroke` | `stroke_ui.rs` | Edit › Stroke… |
| `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) |
| `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation |
| `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N) |
| `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt |
| `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation |
| `__cropPresetNew` / `__cropPresetDelete` | `crop_presets.rs` | New / delete crop preset |
| `__variables` | `variables_ui.rs` | Image › Variables (Define, Data Sets, Apply) |
| `__web` / `__print` | `file_ui.rs` | Save for Web, Print |
| `__colorPicker` | `color_picker_ui.rs` | Foreground/background, nested field/command pickers (see nested keys below) |
| `__colorRange` | `color_range_ui.rs` | Select › Color Range… |
| `__prefsui` | `prefs_ui.rs` | Edit › Preferences… and related preset/import subdialogs |
| `__export` | `export_dialog.rs` | Export As (document or layer) |
| `__sizing` | `sizing.rs` | Image › Image Size… / Canvas Size… |
| `__adjust` | `adjust_dialog.rs` | Image › Adjustments… (also sets `__filter` + `__preview` for live preview) |
| `__filter` | `filter_dialog.rs` (+ `select_mask_view.rs` when `__command` is `select.refineEdge`) | Filter Gallery commands, Edit › Color Settings…, Select and Mask, and other schema dialogs |
| `__form` | `view_cmds.rs` | Simple param grids (e.g. slice options, many Image › Apply Image-style commands) |
| (none of the above) | — | **Bare confirm**: title + OK/Cancel only; OK runs `__command` with fields via generic `confirm` |
**Menu shortcut.** `dialogs::open_command_dialog` opens guide layout, stroke, and color range
directly; then tries `adjust_dialog::open`, then `filter_dialog::open`; otherwise a bare confirm
with `__command` + `__label`. Other dedicated modules are opened from `menus.rs`, tools, or panel
actions with their marker already set.
### Shared private keys
| Key | Meaning |
|---|---|
| `__command` | Engine command id run on OK (bare confirm, filters, sizing, …) |
| `__label` | Menu/catalog label for the title |
| `__document` | Target document id (validate staleness) |
| `__dialog` / `__field` | Nested color picker: parent dialog id and field name to write on OK |
| `__params` | Extra command params for command-targeted color pickers |
| `__preview` | Live canvas preview enabled (filters and adjustments) |
| `__choices` | Dropdown options for `__form` dialogs |
| `__filter` | Filter-schema body (`filter_dialog`); also set on adjustments for preview plumbing |
| `__adjust` | Image › Adjustments editor kind (`adjust_dialog`) |
| `__form` | Generic `view_cmds::form_body` |
| `__formOrig` | Snapshot of non-`__` params at open; Alt+Reset restores it for `__form` dialogs |
| `__variablesOrig` | Snapshot of `__variables` at open; Alt+Reset restores it |
| `__altReset` | Set to `false` to opt a dialog out of Alt+Cancel → Reset |
| `__export` | Export As |
| `__sizing` | Image Size / Canvas Size |
### Routing markers (dedicated `Command` bodies)
| Key | Module |
|---|---|
| `__guideLayout` | `guide_layout_ui` |
| `__fill` | `fill_ui` |
| `__patternFill` | `pattern_fill_ui` |
| `__stroke` | `stroke_ui` |
| `__createShape` | `shape_dialog` |
| `__deleteLayer` | `delete_layer_prompt` |
| `__newLayer` | `new_layer_ui` |
| `__rasterize` | `rasterize_prompt` |
| `__resetAllTools` | `tool_reset` |
| `__cropPresetNew`, `__cropPresetDelete` | `crop_presets` |
| `__variables` | `variables_ui` |
| `__web`, `__print` | `file_ui` |
| `__colorPicker` | `color_picker_ui` |
| `__colorRange` | `color_range_ui` |
| `__prefsui` | `prefs_ui` (string sub-kind: `prefs`, `presetsIO`, …) |
### Nested color pickers (`__colorPicker` on the stack)
Same hub entry as standalone pickers; `color_picker_ui::confirm` branches on extra keys:
| Extra key | Behaviour |
|---|---|
| `__layerStyleColor` | Layer Style child; OK writes effect colour on parent `LayerStyle` dialog |
| `__textStyleColor` | Character/paragraph style colour (type panels) |
| `__shapeFill` | Shape layer solid fill via `shape.edit` |
| `__command` = `layer.fill.solidColor.set` | Solid Color fill layer (no `__shapeFill`) |
| `__dialog` + `__field` | OK updates one field on a parent command dialog (e.g. Fill › Color…) |
## Parallel modal systems (not the main stack)
| System | Location | When |
|---|---|---|
| Unsaved changes | `discard_ui.rs` | Close, Revert, Close Others/All, Exit—Save / Don't Save / Cancel |
| Shell dialog | `control.rs`, `ui.inspect` id `"shell"` | Workspace, custom pixel aspect, show-extras options |
| Camera Raw | `camera_raw_ui.rs` | Separate OS window on desktop; see control-protocol § Camera Raw |
| Error message | `DialogKind::Error` | e.g. `move_lock`—message + OK |
| Nested pickers | `color_picker_ui` (+ markers in **Nested color pickers**) | Stack on parent; parent confirm refused while child open |
## Rules
### Architecture and commands
1. **Thin UI, engine command on OK.** Run `app.run` or a dedicated `*_ui::confirm`; one history step unless the command itself batches.
2. **Register in the hub.** Add `owns()` (or a stable marker), `body()`, `open()` if needed, and matching arms in `dialogs::show`, `confirm`, and `cancel` (and `open_command_dialog` when menu-driven).
3. **Extend existing paths first.** Filters → `filter_dialog`; adjustments → `adjust_dialog`; simple param grids → `__form`; avoid a second modal stack unless product needs Camera Raw–level UX.
4. **`fields` is the automation contract.** Anything agents must drive lives in named fields, not only in local egui state.
5. **Remember last-used values** after successful OK where Photoshop does (`filter_dialog::remember`, prefs `dialogs["…"]`, module-specific helpers).
### Chrome and layout
6. **Use `dialog_buttons`** for OK / Cancel / Apply—platform order via `ButtonRole` (macOS default action in the corner).
7. **Respect width rules** in `dialogs::show` unless `file_ui::dialog_width`, color picker, or kind-specific mins apply (typical 380–440; Layer Style / export / picker up to 600; New Document 800).
8. **Scroll long bodies** inside the content (`DIALOG_CHROME` for filters); title and button row stay visible.
9. **Transparent backdrop**—no dimming; previews are judged at true contrast.
10. **Position:** first open centered; then persist offset (`place_id` per command or kind); title bar drag; clamp on screen.
11. **Stacking:** only the **top** dialog is interactive; non-top instances render disabled; raise the live dialog if an older modal steals focus.
### Keyboard and focus
12. **Tab / ⇧Tab:** text and number fields only. Frame calls `field_tab::begin` / `end`; each field calls `field_tab::register` (`number_edit` does this; custom `TextEdit` must register manually).
13. **Do not rely on egui default Tab** through sliders, dropdowns, checkboxes, or preset cards.
14. **Open on first value:** `first_field_focused` + `widgets::focus_first_field` for value-first dialogs (most filters; Image/Canvas Size; not Select and Mask view keys).
15. **Select-all on focus** when reached via Tab or field arrows (`field_tab::select_all`; see `value_field`, `new_doc_ui`).
16. **Enter** confirms when OK is enabled (`can_ok`); exceptions must be explicit (`guide_layout_ui`, color picker).
17. **Esc** on topmost dialog with no popup open → Cancel. **Click outside does not dismiss** (canvas may still pan/zoom via `free_pointer_over`, `pan_delta`, `free_press`). Esc ignores Alt (it does not become Reset).
18. **Alt** turns Cancel into **Reset** when the dialog opts in via `dialog_alt_reset::supported` (dialog stays open). Opt-in markers: `__guideLayout`, `__fill`, `__variables`, `__colorPicker`, `__sizing`, `__adjust` (all kinds), `__filter`, `__form`. Each module implements its own `reset`; set `"__altReset": false` to disable.
### Canvas and preview
19. **Live preview** for filters/adjustments uses `filter_preview` / `adjust_preview`; `cancel()` clears preview and related shell state.
20. **Under-dialog canvas** respects dialog rects and open popups; Hand / Space / middle-button pan on free canvas only.
### Labels and i18n
21. **Titles:** command dialogs use `__label` (catalogued; trailing `…` stripped in chrome); fixed kinds use `dialogs::title` + `tl!`.
22. **User-visible strings:** `tl!("…")`; command labels from the menu catalog where possible.
### Confirm / cancel safety
23. **Child dialogs first**—refuse parent confirm if a nested color picker is open; cancel parent closes children where implemented.
24. **Validate before close** when params can be inconsistent (`guide_layout_ui::validate` in `confirm`).
25. **Disable OK** when required input is missing (`crop_presets::can_confirm`, Preferences Apply when unchanged).
26. **Preferences over control:** whole-dialog OK/Apply may be refused; safe single prefs via `prefs.set` (see control-protocol).
### Testing
27. **egui_kittest:** open dialog, `run_steps`, key presses, assert `fields` or accessibility roles (`field_tab` tests are the reference harness).
28. **Tab-order regressions:** add tests when a dialog gains multiple text/number fields.
29. **Filter preview:** `dialogs.rs` tests for preview persistence across background jobs.
### Anti-patterns
30. No panics in `body` / `confirm` on bad input—errors to status or validation.
31. No hard-coded colours or radii—`theme::Tokens` and `widgets.rs`.
32. Do not skip `confirm` dispatch—Enter and `ui.dialog.confirm` use it.
33. Do not expect click-outside-to-close on `egui::Modal`.
34. Do not register sliders or dropdowns for `field_tab` unless product explicitly changes Photoshop parity.
## Adding a command dialog (checklist)
1. Choose path: see **Command routing** (`adjust_dialog`, `filter_dialog`, `__form`, `__sizing`, …, or new `*_ui` with a unique marker).
2. `open`: build `fields` (`__command`, `__label`, params, marker); `app.ui.open_dialog(DialogKind::Command, f)`.
3. `body(app, ui, &mut fields)`—mutate `fields` only; use shared widgets.
4. `confirm` / `cancel` arms in `dialogs.rs` (or delegate to module `confirm`).
5. Tab: register every text/number field; use `value_field` / `number_edit` where possible.
6. **Alt+Reset (optional):** implement `reset` on the module and add an arm in `dialog_alt_reset` (same dispatch order as `dialogs::show`). Snapshot open values under a `__*Orig` key when Reset should restore “as opened” rather than factory defaults.
7. Tests: at least open + confirm or Tab sequence if non-trivial; if Alt+Reset is supported, assert Reset restores fields and leaves the dialog open.
8. If menu-visible: command already in engine; menu id opens dialog via existing menu wiring.
## Known gaps
- **Tab registration** for plain `TextEdit` is manual; generic `__form` string fields may not join `field_tab` unless wired.
- **“Dialog module”** is any UI routed from `dialogs::show`, not only files named `*dialog*`.
- **Numeric field parity** (units in expressions, scrubby labels) is tracked in [`ui-parity.md`](ui-parity.md) (Navigation / numeric fields), not duplicated here.
## Revision history
| Date | Change |
|---|---|
| 2026-10-11 | Initial doc from dialog codebase audit |
| 2026-10-11 | Document all `DialogKind` values and `Command` routing markers |
| 2026-10-11 | Alt+Cancel → Reset hub (`dialog_alt_reset`); opt-in markers |

View File

@ -35,25 +35,49 @@ integrate() {
src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop"
[ -f "$src_desktop" ] || return 0
# Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop
# TryExec, which would hide the entry; everything else is shipped as packaged.
# If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is
# already managing this AppImage, do not write a second launcher (#3623); remove our
# own if left over from an earlier run, and still install the hicolor icons below.
apps="$data/applications"
mkdir -p "$apps" 2>/dev/null || return 0
tmp="$apps/.$DESK_ID.tmp.$$"
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
rm -f "$tmp"
else
if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
else
rm -f "$tmp"
external=0
if [ -n "${DESKTOPINTEGRATION:-}" ]; then
external=1
else
for f in "$apps"/*.desktop; do
if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then
continue
fi
if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then
external=1
break
fi
done
fi
if [ "$external" = 1 ]; then
if [ -f "$apps/$DESK_ID.desktop" ]; then
rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
fi
else
rm -f "$tmp"
return 0
# Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop
# TryExec, which would hide the entry; everything else is shipped as packaged.
tmp="$apps/.$DESK_ID.tmp.$$"
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
rm -f "$tmp"
else
if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
else
rm -f "$tmp"
fi
fi
else
rm -f "$tmp"
return 0
fi
fi
# Icons: per-file compare so an unchanged install costs a handful of cmp calls.

View File

@ -13,3 +13,6 @@ StartupWMClass=ai.storyteller.photocraft
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
Keywords=photo;image;editor;psd;layers;retouch;paint;
MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
# AppRun registers the desktop entry and icons itself (#593); tell external
# AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623).
X-AppImage-Integrate=false

View File

@ -48,6 +48,7 @@ expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\" %F"
grep -Fxq "$expected_exec" "$DESKTOP" || fail "Exec not rewritten: $(grep '^Exec=' "$DESKTOP")"
grep -q '^TryExec=' "$DESKTOP" && fail "TryExec kept, the entry would be hidden"
grep -Fxq "Icon=$APP_ID" "$DESKTOP" || fail "Icon line lost"
grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing (#3623)"
grep -Fxq 'Categories=Graphics;2DGraphics;RasterGraphics;Photography;' "$DESKTOP" || fail "metadata lines lost"
if command -v desktop-file-validate >/dev/null; then
desktop-file-validate "$DESKTOP" || fail "generated desktop entry is invalid"
@ -71,23 +72,40 @@ export APPIMAGE="$TMP/moved.AppImage"
"$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero"
grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not refreshed after move"
# 6. Opt-out installs nothing.
# 6. External AppImage integrator (#3623): if another .desktop file in $XDG_DATA_HOME/applications
# already launches this AppImage (e.g. AppImageLauncher's appimagekit-*.desktop), AppRun removes
# its own duplicate .desktop entry while keeping the hicolor icons installed.
ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop"
printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch with external integrator desktop entry exited non-zero"
[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside external integrator entry"
[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed"
[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator"
rm -f "$ext_desktop"
# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry.
rm -rf "$XDG_DATA_HOME"
DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero"
[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry"
[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION"
# 8. Opt-out installs nothing.
rm -rf "$XDG_DATA_HOME"
PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero"
[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated"
# 7. An unwritable data dir never blocks the launch.
# 9. An unwritable data dir never blocks the launch.
mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro"
out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir"
[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out"
chmod u+w "$TMP/ro"
# 8. No $APPIMAGE (extracted AppDir): launch only, nothing installed.
# 10. No $APPIMAGE (extracted AppDir): launch only, nothing installed.
rm -rf "$XDG_DATA_HOME"
env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero"
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE"
# 9. A path that would need Exec escaping (here a `$`): launch only, nothing installed.
# 11. A path that would need Exec escaping (here a `$`): launch only, nothing installed.
rm -rf "$XDG_DATA_HOME"
odd="$TMP/odd\$(id).AppImage"
: >"$odd"

178
xtask/pngsuite.sha256 Normal file
View File

@ -0,0 +1,178 @@
71e1f5bf865131b5d9996e76a78372146283e5089e50f900ae927a6458b62101 PngSuite.LICENSE
a2853f2be68e460dcb9992819125b74503a01383e79f79a04b971ffa9d248544 PngSuite.README
6cf3bcd1757bfad2a7ce9c9659d4f609297a0828cafc7c9eddee18c5576ba9e9 PngSuite.png
9bf1c4610280efecc291361e2b7b026d602d03a7137f476ff84af626bb7b3a62 basi0g01.png
50ed4ff9c1c80578ae58ead6fb28ea2e44016b5b56a7b3e95c235ec86f97c0d8 basi0g02.png
0cce4212157dae7c0f71f5f72cef39cecab3347de455da0ace7de83eebd47849 basi0g04.png
b9300750e7414c9fed68cc7f4f531f5e2cdd2724f50142e8aa3a0070836bece9 basi0g08.png
d354026bdd0aa6ed7bedea7c92e5eef2ebbd4854848b8a0a48f562cc71bfe80f basi0g16.png
b2690d4475cdc39faf5a7d2de20de4e14eb96c6360fa791ec2003b4085ecacde basi2c08.png
ae13eba5fabbf7cbe8c01ec4458cdaa7e6bfdfcbd5109e831efaac9f39096027 basi2c16.png
8e1edfbc0382e0b545c70209271cedead2d530df1ce40f5296f0749226b36006 basi3p01.png
85f304eaaf6831b2bcc2c08892b947fef52f06337aaf23312d6326e367d73f77 basi3p02.png
78890ee2c4da71e460b5a1527efe7dc17d673ebe997f49ca1b4dcd7347d21e56 basi3p04.png
ca103eef7a7c50abc7af9d5ec0dc9fdc07668e2f030adbce226e050dcec9eea3 basi3p08.png
dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b basi4a08.png
ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e basi4a16.png
fe70ea20c8447e82abb7bc9dd05344a61a4631cc69e0974378df8f6263af0337 basi6a08.png
7fdd6bf08f04692bcf06b5c4262e7f76c49d8adb992789fd3f05aa0435645cd8 basi6a16.png
c8b1364d7771dd2f5a1b2d7d633abcf3f48dafee608558ecd2e5fc98f61894cd basn0g01.png
ca172e92d179c77b3d321ad501e452a59bdaafafde8ebd74fd0669c4eae58efc basn0g02.png
fb2d42c4c8f5c51590e675994a9b0c718fbf71c80e28142f231cfa7c5b0e26e8 basn0g04.png
268d061075d1dd2eeec62b31303d09f6998549e1bfb447a5f09c80a2b0978ac3 basn0g08.png
1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 basn0g16.png
c90e86090a625661b19960cafdde6e347d6e32d73837aaae533f66dd3f099506 basn2c08.png
1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b basn2c16.png
c2d7cd682df5f74506b33a5d70c344aaee248fda79fdfef8e873426fd6f2b75b basn3p01.png
0466bb7ed9984cf03b70704564bcffab1df8ec0e8167473ba0f75e4fedce5a8f basn3p02.png
e1fc7be978d3149b98533d0076245ae64353b7967290f4204c1282ecb4ec1aba basn3p04.png
d58256cd2eb16b5740d4c1403d25ce43d8dd03e270627ab709d2fb141e3d904c basn3p08.png
7be24b618bfd437b921ae0caf9341d112338c6290cc62466deb19d2c7a512968 basn4a08.png
1c92ffd11d2fc89a36f170d0239668436407f5c8e3f8d93483fb3fc6bca361d7 basn4a16.png
559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 basn6a08.png
8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa basn6a16.png
dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b bgai4a08.png
ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e bgai4a16.png
559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 bgan6a08.png
8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa bgan6a16.png
71d041df5c8949ce07d5e93b3c9f752cefc75ef7326c49df05ae73915f1a755f bgbn4a08.png
c45de44d3442bd33072d68456541b10e0cc57c5d8bfc809208995d939091eb2d bggn4a16.png
2da3c3efa01bf166958852a104ece92daaede8ffb5e0452a55d40526258a8bf5 bgwn6a08.png
875bc2b44a4355551c51260def9664da68be1af8c14b82f76173aaf01ddb1929 bgyn6a16.png
c88909e74e039dd3df829bf24144b487171e53b17f5c5c07cbc08688247d24b5 ccwn2c08.png
dc365b39d49c287669d837872dd59aef30763a611dfc8c046c481e35e49a390a ccwn3p08.png
438018f19c85e582cb586ac7cca2220008ecb7fc70ce50e8f2a76b494c128a20 cdfn2c08.png
a068eaf1f7040490e08eda3259befb6689849dd0ff8bb4cc03c705d117cb2b9f cdhn2c08.png
ee08dfa303a3c09171280fc1821180dc0a4c3591af7e9077e8ce0435642c76d2 cdsn2c08.png
a849d89ba3d92146940d7c56d2fe74c9c3ec96725c7f24eefaf44160a6304de2 cdun2c08.png
587a8624228d3f605f475bd751a1f3a97f0bbab590c0ff518bce5da3abcb6f2b ch1n3p04.png
e3cace549047c6e5e693b9b135d53f7f9ca5b1088bf30cc408634598ba53758a ch2n3p08.png
79ccaf4abe5091e1f65f8a61496363ed3312ebb9ea1aaa98affadac3d98507de cm0n0g04.png
127bb50d224ac3ebff36160482a03b9e6686072e86c8ff4cd43e3ce2271b2315 cm7n0g04.png
774b384d0ad73698e7d93fc5a29976b332c619045e5966276c609b11459a4152 cm9n0g04.png
bc812de2b83abc9ae2a6d1ae01e91f9090214b9ed8fee1d56cdfea58f4f259d0 cs3n2c16.png
267104dced0858dd828cdd41edc0e87042c7094c77191565192a67045064b23f cs3n3p08.png
028fa49fbba3954c2eba27a4c3607196e87c1dae74192df3b3ad7afe5e0a3f0c cs5n2c08.png
a7a4021983d6b22685529cbafb65cd087c9e1af1c5d205c2e13e3efc4ae87b6a cs5n3p08.png
d90ddab7313e4e1fd7f20f16a6c546b449697fbcac1a7776298a74896f4662da cs8n2c08.png
5bffdb88e307a851f8e85b137a69c59e557a881db816e990061934c0155c894c cs8n3p08.png
081d1ec26b4157fbc032b76dc716321420f2d032a425de046557c7842766826d ct0n0g04.png
259116f8ecf849d83d824688eb02b4575aed11980f02ab6fb123281c25a6459d ct1n0g04.png
4583e63d1bdfa18b6abb47439dee9a73bed311ed1b0a71d22bed75fe4767832b cten0g04.png
d0607280c3539a8934c5cfd22788b382cdc31321cdedc75ae8a29e3948580ba6 ctfn0g04.png
5f4c87f5a8f589d029a908050880ac5d6aed3810e55e75444802d6d3fb27cf33 ctgn0g04.png
8ef799e59871755578e563c5b411bc3895c2f7112c115aaa921b6c04fe255bf2 cthn0g04.png
ca91332ecc04e5faac1f39ca4df4996b072a93f3e574e7f4b50d52aafbfe67b6 ctjn0g04.png
c0765e635a6423ec64f0314400c3df20745c4a4469ffcc323c7b00ba66b4aceb ctzn0g04.png
d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809 exif2c08.png
d055cc0bb505b37c6ecf88a808d71675eca5dd0ee683d70ebc9c0be0e1bb8e57 f00n0g08.png
0fe92b2aa2da04c885d1dbd85c834716f6cdd946364d97dcd597bb79d9e14427 f00n2c08.png
956ddaf133e6d9a8d36f1e00604d87fbc2d0295e933bb8caaef517e7d87e5342 f01n0g08.png
ef072ec6815ebf9b33e0553d2e4e4e7ed6911860a2512c67bcd10a9f0f09b9de f01n2c08.png
1f5b49c06ecc2a1756f0423b3de936bd18497866d035ac8eded78a6f17408a2c f02n0g08.png
ca4b937b3c587d5c007f193a2eec14dc96b0d23ff7d6aa9004e3badd1af9fe8f f02n2c08.png
d0563ae30c6ce7a01eae7a5cdc0ef780175bfa7653888ed93f17bef5fe4b2107 f03n0g08.png
2d101e3ef4f78a69437034671e93fe11faac0cfc4d44210dcca1b944caa886f7 f03n2c08.png
fec1a3c53833d0ddefa4486a98934c393cb4335b2dc31da70e6540a4686f1457 f04n0g08.png
c365c24153cb69fd3c162f00b296ae23a71a1595645d1aeb0ad23af680d7b4be f04n2c08.png
8ac0f095d2a943157e820fa121bccde08d5230af1b5830c3041d5f4da3524eba f99n0g04.png
3494b914dd1b094afa9a74a89bfa75219030e21af6fca3ed66c9d45032a047e9 g03n0g16.png
abce774b9624952c2d0c57ff681790ba620cf5616fe7e0ef0600600cdce3c22a g03n2c08.png
541389db0c72721c6c49501e1dbf6e32df41b4fcfa318676955e278f3f4332b3 g03n3p04.png
72350a2d9db2df09b626f1f1dd48061e9c749d471bf19814d2c5c10acbf007e5 g04n0g16.png
bfd3edfc4d85a43383556a51023533fa3888184aa53a6b43769910782beebfce g04n2c08.png
5ea85ebefb58b2ab113fc05bf1cb79ffb068359e3a5620843590a0a7b59bd49a g04n3p04.png
be19721d28e0b268af1e29e8190c952b94315bd08899383ae2c91e6dc8c4f889 g05n0g16.png
74ab5b8477992d65e220eeaff8df466522c1b392fa90c6f9935da5bdb7113c9d g05n2c08.png
2f46afd7ec15836b523cbce6965f34d89833b8d777afffaf0f53bce620d5f65e g05n3p04.png
9070f843981f765cc8e26a63751be5d58631d85a8a7b4e826358f80e8a679d03 g07n0g16.png
ca4cd32d222f65fb7beb7c9f40d8b6833b36d552b830ff1a1d67dae73b8ed0d8 g07n2c08.png
b8e7abeabcfd50b71cca78b6cdff7372310cfc179cd63304c3e904f469b333cb g07n3p04.png
a22486acb74ee5f947db3a20b1f314a9dd8aca3339840049ee559f4d676a6b5c g10n0g16.png
4ac23729aea109e8f6b1e831448115cf02dcd97559e9403847cbe6dd7e5c7347 g10n2c08.png
36cc2ea4b5b33cd18e0a01c7de85dbcaf7161d258cd0f2265a90cd835b15cf6d g10n3p04.png
1fae707d809296d2cf0d6aa653bead6199644fb1c859558ec7d2d69e709a423a g25n0g16.png
9b128cfa1bb417dd99251914d729062621fcea9f346168549b16a75fe030b0f6 g25n2c08.png
4197a0a25c4f74d42c2285d75b9fabe1eedbed45bc224ff1ec1784f5797e9dbf g25n3p04.png
1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 oi1n0g16.png
1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b oi1n2c16.png
142bc4b1e9f8a9e3b0ba3d2c2025e485bf7e76df88f914bf8c9041b4562cc080 oi2n0g16.png
2c523f138e0ade1bed8f31d59ec0f34b82660b192d6af8b8f395f82b9ba9dabb oi2n2c16.png
aa2d77723ba44f654a41a2ab97e01271fe942fecd2f299c0a81da90acb2887ac oi4n0g16.png
661220a0ed03ca6d63ec8dcaac738d4a9fd089b491c40dd896267ffb6cf5015d oi4n2c16.png
ae70c9effd0d9957a0d42771bc987529a1f3715e068057fae63f0c7e50d5c53a oi9n0g16.png
fef98e9bebe6c4046c29f2568f7a735305c8661aa01f9c37a718135e5bc4f941 oi9n2c16.png
72406b6173ec20dd059e8cea594ef5bc8f81bac5e416e2b53058d2debd51d195 pp0n2c16.png
d5a6f964a601a5e1815525117c12c32b61e2aa09fd6040776d35f2f7115b7110 pp0n6a08.png
b321b9ac8f2aa641475b6936365954181726dcb8662dbf641284d82a83cc22a5 ps1n0g08.png
6c59d673032fe2d04dbd2ea91cc9bc7bebe7d5ab84911f00e36fb28994793576 ps1n2c16.png
bd2ae1b45fd771aaa8c57dccd183f3834fc9742f6dfc692606865049efb116e5 ps2n0g08.png
120c62ce9d014f7772c4b38a446991b5bbe6a22ef5ea59239d32e023bc167585 ps2n2c16.png
235eb82f1337e3fcefbdca566a53af3d3523db7588ba3cd7fa5dab19afe48a41 s01i3p01.png
6a4f6da9fe36d195219ad2beeac1a0423e2ee6de03dfb064a3b6fa31623eb230 s01n3p01.png
e7d26ab4b31e7066b0674a4374c093967a9f1761b96836aa3dbe624acd73ab52 s02i3p01.png
f4b324f2b1d013ee77f632e42129c7cd3eca55da846e775e744a1f224ad1efcd s02n3p01.png
4513dec80f54ffd04a28834b114254d6e6d66e52e32e0059174474f2fb15d6bc s03i3p01.png
4dee297bd7f747f87488509af11a1e1e0531fa67e6e8d0f8ab7f4df7940f0ff3 s03n3p01.png
918f94f3557e10de91efa680df507c8eafc347fa5023af43037701398128ad0f s04i3p01.png
e73c52f764b684ec5133cce11f7f18cc60065acdd308e2c3d871e0a792a1080e s04n3p01.png
77fe040e17167f9656afe46cf0031832e635745e4e7f19ba12dee32f5ec03d96 s05i3p02.png
1b9e244a5c1d6cc9830391fff4eac565ada026d000e60faf7525c8add6bdc726 s05n3p02.png
cf8d5fdb34c0afd38b1b4745bed6167771767ccdfe847c4d8766f7fcc5c41176 s06i3p02.png
5caabfc4b147fad5e49e558143faaab91888324240acf45e5db8fc49f7a7f667 s06n3p02.png
d9874bfad10353a9ea2876ee388c3e7f81f697ed141d0d79c5c2019d62a7a698 s07i3p02.png
e7d447394f5fa64c27353216af387352a77a1fb534922a2d974e60d965d4e414 s07n3p02.png
62adf855c6b1e3a007f9dcf3ce2ba244616c089269a511dd939e89a4b0853453 s08i3p02.png
544fdd476e566e8aef4c98604124ae05acef48be20d1aebb9b6674b08b2abbb6 s08n3p02.png
f2cf5b3109690704d106a0b22ea079e36064cc6077e1fb9a1ba87397d0bbcca1 s09i3p02.png
6be8f58b638e7f7114750f5796f66f650db8fb7242a36183850f02117f4eaa65 s09n3p02.png
a27e0dbf923ef868e7399560b76c23a50b2d8926cddd9775e2bc4e050273f205 s32i3p04.png
c80d9fc7ded0b7834ec11d0e324540c7be74016b61bc302a1d167218fa35fbf9 s32n3p04.png
cc6ad98dcf686d92f1165d951f90fac70e568cedb1fcbba33e8890d0e6cdad4d s33i3p04.png
998d08e084e3eea288984475bfed0d48ee2d8250e20b96755898b59152e36a34 s33n3p04.png
83eb2e82eb14ae3c608833ef371a6f472c4c6e63b7e9f4f92f5472f975793b97 s34i3p04.png
a25451168c0b03ab8f52dcc60c6b9c12bc5df5d55afabe1892b87d0a856bb4a0 s34n3p04.png
db5988c27ad577de1e494cb50229dc2772678531adde2db84493907eae003e54 s35i3p04.png
cd99fc4cc69bda1ff8e8697d8b423ae8d9e84a58ba1d2b480751953b6869e3b3 s35n3p04.png
a36b40927b730a31d60c7818fb4a32e67ace06393cdaa26edc09520b6cee066d s36i3p04.png
ebc08f6a1e0918c5d3a9a08e184c23019a3cf90d8843e4b71bf2ae16040b1a84 s36n3p04.png
565c79404c4b9e46c27edb2f93d7b26b93ec0a0481ab7a21fc405152a21f53ba s37i3p04.png
964078b1f3f472268b8ac97a8976b4d1cdb6a47e6243d67eddcb0205e65b9f61 s37n3p04.png
b107f120ffaffcb949ac4547e0f91555ac681f78c7e68c724e7ceddfd8e5387b s38i3p04.png
045b0f5ea35874741bf6cac40f2e63ec8726450ab241475df4b7e9303c39e339 s38n3p04.png
4b5a6d8c04c8c70e1de3bcb6f7b5cc3ba359b13e28de6f52e21a291d4bc8a937 s39i3p04.png
2115b6cad0140ea97fe6ec056b47307ef1e8b5c00db5f08707a0572b165e5f08 s39n3p04.png
48a3f19b5c3b2f7eca17fd34765a2a3e385d770f24c357bc66bd74417eb60a5a s40i3p04.png
c4598ea73b1ec015bef7dcfd7f67bc7bfc41546619953d1a92cd9b8657a7033a s40n3p04.png
c5e7eeaffa677bfac002ee968a0423e0fea3be7701d39ccd6a015df1d2d85e40 tbbn0g04.png
8fcfd031a5b716201854fdab25691feed6cd3ed7b713a51b94e1f43fc63fe0c1 tbbn2c16.png
8eb43a6020fda8ed7c8c9b88b974ef794ee8452db29212ae7e5c11003bfd76a3 tbbn3p08.png
49711ee271f96099521b83b75d58ed2f76d42ed46cb3c06656fde2cad697cf59 tbgn2c16.png
6f798628935a021fe446dce3d62861d1f7369aaf01bad10daa4917c5abccad7d tbgn3p08.png
8223d8b0c5f84b149f0c1f0e1f658346e07fcbd03ffc02b2e9eeea1481eb19bf tbrn2c08.png
01e86f108b244bdee4d72e384aced8a62369965ca2993ee6ef88f131c647f7d1 tbwn0g16.png
978b28cec43bd219517083b63661bb30a7b9cdf9fd4476ab4e70844565980d51 tbwn3p08.png
7b4eb64b4bd7eb36417752873ddaca027ce77a07f474b2af569f4eea1e984111 tbyn3p08.png
f05e317f64a038123faf96fdbd6fc3ab3eec14e1fc1eb87fd7da4d996be63526 tm3n3p02.png
a253e055d8cd94049cd95e9392e7aa60f3d5c922a8f0cccb84762b0c5f547545 tp0n0g08.png
5dced3fde56b950e61ce86f4ad54458336f66389ce8ca68524d4fd8ec546ff5b tp0n2c08.png
da40ddb18bd45db32ed03dc89e2250b096d808efe975a88477e4f5a91a4677c9 tp0n3p08.png
50bc927fe6cf1816454a7c27f8c743734be8db45e37e5ee3ede540f4e895e6c5 tp1n3p08.png
4059f7e6a1c5bac1801f70e09f9ec1e1297dcdce34055c13ab2703d6d9613c7e xc1n0g08.png
e252a0e7df3e794e52ce4a831edafef76e7043d0d8d84019db0f7fd0b30e20f4 xc9n2c08.png
3c0c2a68dd416a7be79400e0e2ceb358ba3191d5309d4bad9dd04b32ff6d5b60 xcrn0g04.png
71e4b2826f61556eda39f3a93c8769b14d3ac90f135177b9373061199dbef39a xcsn0g01.png
c1287690808e809dc5d4fb89d8a7fd69ed93521f290abd42021ca00a061a1ba4 xd0n2c08.png
00b53c3bbd0641454521b982bc6f6bcfda7c91f1874cefb3a9bac37d80a1a269 xd3n2c08.png
16e5b40fb2600db1af20fb79ff715c2869255e2d4bef20702f16534e5dd6a847 xd9n2c08.png
f9d1fb2a708703518368c392c74765a6e3e5b49dbb9717df3974452291032df9 xdtn0g01.png
318864720c8fc0dbe4884035f2183cc3bd3d92ec60d447879982942600e9fe2e xhdn0g08.png
968fd21abb8acf40fbca90676de3abcb3a6e35b01ba7a3b9190184eafb99c83d xlfn0g04.png
776701227c7094dd10b78f508430b8ea4f03471d072096382cbcca628dea1d2b xs1n0g01.png
9dd7e93ba9211f0caee09d8f12e37742936c469f45fcad21e577c9f0640cf99e xs2n0g01.png
166c8633d116d1c26631cb98f3a75340cf4385fa7ae6a67913bc78e709bcb30d xs4n0g01.png
7a380568beeac969908196ac89cd2347c96ac2f26fdc2b8e0314b62c59f0e308 xs7n0g01.png
e4767d3259e1b331c9ece9fb07fc30f385879ac12b3cd0e1cb923f3bfac81dcf z00n2c08.png
939abfc6e0a3e349bb5bcd8c4e427a6cdb2c7e0c23a773aefa1c4fc9f07e4cb4 z03n2c08.png
21652fe42f472acd83dcb207806add5737db0020b21d6b38b0a00be3e9e37990 z06n2c08.png
98d90a7e61aa1a9ac9d99af44dd25c70cd5a7b4a11a85da60f82923e18a78f9f z09n2c08.png

View File

@ -1,10 +1,15 @@
//! `cargo xtask corpus [...]` (fetch test corpora) and `cargo xtask test-corpus [...]` (fetch,
//! then run the corpus tests). Pins: `corpus_pins.rs`. Docs: `docs/development.md` › Test corpora.
use std::path::PathBuf;
use std::process::Command;
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::corpus_pins::{self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT};
use crate::corpus_pins::{
self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256, PNGSUITE_URL, PSD_TOOLS_COMMIT,
};
use crate::pinned::{PinnedCorpus, USER_AGENT};
use crate::{cargo, root, run};
@ -119,7 +124,7 @@ fn status(present: bool) -> &'static str {
fn list() {
let corpus = root().join("corpus");
let png = corpus.join("pngsuite");
let png_ok = pngsuite_count(&png) > 0;
let png_ok = verify_pngsuite(&png, PNGSUITE_MANIFEST).is_ok();
println!(
"Test corpora live under {} (gitignored, never committed). The corpus tests are opt-in
(cargo feature `corpus`) and fail, not skip, when a corpus is missing. CI always runs them.
@ -162,31 +167,212 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then --<name> --update-ma
);
}
fn pngsuite_count(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0)
const PNGSUITE_MANIFEST: &str = include_str!("../pngsuite.sha256");
const PNGSUITE_MAX_ARCHIVE_BYTES: u64 = 1024 * 1024;
const PNGSUITE_MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
static PNGSUITE_STAGE_ID: AtomicU64 = AtomicU64::new(0);
/// Do not trust cached filenames, symlinks, or a file's size remaining unchanged during a read.
fn pngsuite_bytes(path: &Path, limit: u64) -> Result<Vec<u8>, String> {
let meta = std::fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() || meta.len() > limit {
return Err(format!("{}: expected a regular file of at most {limit} bytes", path.display()));
}
let mut bytes = Vec::new();
std::fs::File::open(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.take(limit + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("{}: {e}", path.display()))?;
if bytes.len() as u64 > limit {
return Err(format!("{}: exceeds {limit} bytes", path.display()));
}
Ok(bytes)
}
/// PngSuite: 175 PNGs from a fixed release archive (skipped when already present).
fn fetch_pngsuite() -> Result<(), String> {
let corpus = root().join("corpus");
fn verify_pngsuite(dir: &Path, manifest: &str) -> Result<(), String> {
let mut expected = BTreeMap::new();
for line in manifest.lines() {
let Some((hash, name)) = line.split_once(" ") else { return Err("pngsuite: invalid manifest line".into()) };
if hash.len() != 64
|| !hash.bytes().all(|c| c.is_ascii_hexdigit())
|| name.is_empty()
|| name.contains("..")
|| !name.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
|| expected.insert(name, hash).is_some()
{
return Err("pngsuite: invalid manifest entry".into());
}
}
if expected.is_empty() {
return Err("pngsuite: empty manifest".into());
}
let meta = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
if !meta.is_dir() {
return Err(format!("{}: expected a regular directory", dir.display()));
}
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut seen = 0;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let name = entry.file_name();
let hash = name.to_str().and_then(|n| expected.get(n)).ok_or_else(|| format!("pngsuite: unexpected file {}", entry.path().display()))?;
let bytes = pngsuite_bytes(&entry.path(), PNGSUITE_MAX_FILE_BYTES)?;
if crate::sha256::hex(&bytes) != **hash {
return Err(format!("pngsuite: {}: sha256 mismatch", entry.path().display()));
}
seen += 1;
}
if seen != expected.len() {
return Err(format!("pngsuite: missing files ({seen} of {})", expected.len()));
}
Ok(())
}
struct PngSuiteStage {
path: PathBuf,
keep: bool,
}
impl PngSuiteStage {
fn new(corpus: &Path) -> Result<Self, String> {
std::fs::create_dir_all(corpus).map_err(|e| format!("create {}: {e}", corpus.display()))?;
for _ in 0..100 {
let id = PNGSUITE_STAGE_ID.fetch_add(1, Ordering::Relaxed);
let path = corpus.join(format!(".pngsuite-{}-{id}", std::process::id()));
match std::fs::create_dir(&path) {
Ok(()) => return Ok(Self { path, keep: false }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(format!("create {}: {e}", path.display())),
}
}
Err("pngsuite: could not create a staging directory".into())
}
}
impl Drop for PngSuiteStage {
fn drop(&mut self) {
if !self.keep {
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
/// Keep the old cache until the verified replacement is in place. On a failed rollback its
/// backup remains in staging and the error names it, rather than deleting the only copy.
fn install_pngsuite(
dest: &Path,
files: &Path,
backup: &Path,
backup_retained: &mut bool,
mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>,
) -> Result<(), String> {
let had_cache = match std::fs::symlink_metadata(dest) {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(format!("{}: {e}", dest.display())),
};
if had_cache {
rename(dest, backup).map_err(|e| format!("pngsuite: preserve {}: {e}", dest.display()))?;
*backup_retained = true;
}
if let Err(e) = rename(files, dest) {
if had_cache && let Err(restore) = rename(backup, dest) {
return Err(format!("pngsuite: install failed ({e}); restore failed ({restore}); old cache retained at {}", backup.display()));
}
*backup_retained = false;
return Err(format!("pngsuite: install failed: {e}"));
}
*backup_retained = false;
Ok(())
}
fn fetch_pngsuite_in(
corpus: &Path,
archive_hash: &str,
manifest: &str,
download: impl FnOnce(&Path) -> Result<(), String>,
extract: impl FnOnce(&Path, &Path) -> Result<(), String>,
rename: impl FnMut(&Path, &Path) -> std::io::Result<()>,
) -> Result<(), String> {
let dest = corpus.join("pngsuite");
let have = pngsuite_count(&dest);
if have >= 175 {
println!("pngsuite: {have} PNG files in {} already", dest.display());
if verify_pngsuite(&dest, manifest).is_ok() {
println!("pngsuite: {} already matches its manifest", dest.display());
return Ok(());
}
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let tgz = corpus.join("PngSuite-2017jul19.tgz");
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(PNGSUITE_URL);
run(curl, &format!("curl {PNGSUITE_URL}"))?;
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
let _ = std::fs::remove_file(&tgz);
let n = pngsuite_count(&dest);
println!("pngsuite: {n} PNG files in {}", dest.display());
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
let mut stage = PngSuiteStage::new(corpus)?;
let archive = stage.path.join("archive.tgz");
download(&archive)?;
let bytes = pngsuite_bytes(&archive, PNGSUITE_MAX_ARCHIVE_BYTES)?;
if crate::sha256::hex(&bytes) != archive_hash {
return Err("pngsuite: archive sha256 mismatch; nothing extracted".into());
}
let files = stage.path.join("files");
std::fs::create_dir(&files).map_err(|e| format!("create {}: {e}", files.display()))?;
extract(&archive, &files)?;
verify_pngsuite(&files, manifest)?;
let backup = stage.path.join("previous");
install_pngsuite(&dest, &files, &backup, &mut stage.keep, rename)?;
println!("pngsuite: verified {}", dest.display());
Ok(())
}
/// Older curl versions cannot enforce --max-filesize for unknown-length responses. Write
/// only the allowed prefix, then probe for excess without spooling that byte to disk.
fn copy_pngsuite_download(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<()> {
std::io::copy(&mut reader.take(PNGSUITE_MAX_ARCHIVE_BYTES), writer)?;
let mut extra = [0; 1];
loop {
match reader.read(&mut extra) {
Ok(0) => return Ok(()),
Ok(_) => return Err(std::io::Error::other(format!("pngsuite: archive exceeds {PNGSUITE_MAX_ARCHIVE_BYTES} bytes"))),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
fn finish_pngsuite_download(child: &mut Child, mut reader: impl Read, writer: &mut impl Write) -> Result<(), String> {
let result = copy_pngsuite_download(&mut reader, writer).map_err(|e| format!("pngsuite: download: {e}")).and_then(|()| {
let status = child.wait().map_err(|e| format!("pngsuite: wait for curl: {e}"))?;
if status.success() { Ok(()) } else { Err(format!("pngsuite: curl failed: {status}")) }
});
if result.is_err() {
let _ = child.kill();
let _ = child.wait();
}
result
}
fn download_pngsuite(mut curl: Command, archive: &Path) -> Result<(), String> {
let mut file = std::fs::File::create(archive).map_err(|e| format!("create {}: {e}", archive.display()))?;
let mut child = curl.stdout(Stdio::piped()).spawn().map_err(|e| format!("pngsuite: start curl: {e}"))?;
let Some(stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return Err("pngsuite: curl stdout unavailable".into());
};
finish_pngsuite_download(&mut child, stdout, &mut file)
}
/// PngSuite: fixed archive checked before tar; every cached file checked on each invocation.
fn fetch_pngsuite() -> Result<(), String> {
fetch_pngsuite_in(
&root().join("corpus"),
PNGSUITE_SHA256,
PNGSUITE_MANIFEST,
|archive| {
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "--max-filesize", &PNGSUITE_MAX_ARCHIVE_BYTES.to_string(), "-A", USER_AGENT, "-o", "-", PNGSUITE_URL]);
download_pngsuite(curl, archive)
},
|archive, files| {
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(archive).arg("-C").arg(files);
run(tar, "tar -xzf PngSuite-2017jul19.tgz")
},
|from, to| std::fs::rename(from, to),
)
}
/// Files changed relative to `origin/main` (committed, staged or not).
@ -286,4 +472,308 @@ mod tests {
fn all_without_update_manifest_only_verifies() {
assert!(calls(false, false).iter().all(|(_, u)| !*u));
}
const FIXTURE_FILES: &[(&str, &[u8])] = &[("a.png", b"alpha"), ("b.png", b"beta"), ("PngSuite.LICENSE", b"fixture licence")];
const FIXTURE_ARCHIVE: &[u8] = b"owned archive fixture";
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let id = PNGSUITE_STAGE_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("photocraft-pngsuite-test-{}-{id}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn cache(&self) -> PathBuf {
self.0.join("pngsuite")
}
fn old_cache(&self) {
std::fs::create_dir(self.cache()).unwrap();
std::fs::write(self.cache().join("old.png"), b"old cache bytes").unwrap();
}
fn assert_old_cache(&self) {
assert_eq!(std::fs::read(self.cache().join("old.png")).unwrap(), b"old cache bytes");
assert_eq!(std::fs::read_dir(&self.0).unwrap().count(), 1, "staging was not cleaned");
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn fixture_manifest() -> String {
FIXTURE_FILES.iter().map(|(name, bytes)| format!("{} {name}\n", crate::sha256::hex(bytes))).collect()
}
fn fixture_extract(_: &Path, files: &Path) -> Result<(), String> {
for (name, bytes) in FIXTURE_FILES {
std::fs::write(files.join(name), bytes).map_err(|e| e.to_string())?;
}
Ok(())
}
fn fixture_download(archive: &Path) -> Result<(), String> {
std::fs::write(archive, FIXTURE_ARCHIVE).map_err(|e| e.to_string())
}
fn fixture_fetch(corpus: &Path) -> Result<(), String> {
fetch_pngsuite_in(corpus, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| {
std::fs::rename(from, to)
})
}
#[test]
fn pngsuite_cache_checks_bytes_names_and_regular_files() {
let f = Fixture::new();
std::fs::create_dir(f.cache()).unwrap();
fixture_extract(Path::new("unused"), &f.cache()).unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).is_ok());
std::fs::write(f.cache().join("a.png"), b"changed").unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("sha256 mismatch"));
fixture_extract(Path::new("unused"), &f.cache()).unwrap();
std::fs::remove_file(f.cache().join("a.png")).unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("missing files"));
fixture_extract(Path::new("unused"), &f.cache()).unwrap();
std::fs::write(f.cache().join("extra.png"), b"extra").unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("unexpected file"));
std::fs::remove_file(f.cache().join("extra.png")).unwrap();
std::fs::remove_file(f.cache().join("a.png")).unwrap();
std::fs::create_dir(f.cache().join("a.png")).unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("regular file"));
}
#[test]
fn pngsuite_valid_cache_needs_no_download_or_extraction() {
let f = Fixture::new();
fixture_fetch(&f.0).unwrap();
fetch_pngsuite_in(
&f.0,
"unused",
&fixture_manifest(),
|_| panic!("valid cache downloaded"),
|_, _| panic!("valid cache extracted"),
|_, _| panic!("valid cache renamed"),
)
.unwrap();
}
#[test]
fn pngsuite_repairs_unverified_175_file_cache_and_removes_extras() {
let f = Fixture::new();
std::fs::create_dir(f.cache()).unwrap();
for i in 0..175 {
std::fs::write(f.cache().join(format!("fake-{i}.png")), b"not a PNG").unwrap();
}
fixture_fetch(&f.0).unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).is_ok());
assert_eq!(std::fs::read_dir(f.cache()).unwrap().count(), FIXTURE_FILES.len());
assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 1);
}
#[test]
fn pngsuite_unexpected_or_truncated_archive_never_reaches_extractor() {
for bad in [b"unexpected".as_slice(), &FIXTURE_ARCHIVE[..4]] {
let f = Fixture::new();
f.old_cache();
let e = fetch_pngsuite_in(
&f.0,
&crate::sha256::hex(FIXTURE_ARCHIVE),
&fixture_manifest(),
|a| std::fs::write(a, bad).map_err(|e| e.to_string()),
|_, _| panic!("unverified archive reached extractor"),
|_, _| panic!("unverified archive reached installation"),
)
.unwrap_err();
assert!(e.contains("archive sha256 mismatch"));
f.assert_old_cache();
}
}
#[test]
fn pngsuite_download_or_extraction_failure_keeps_old_cache() {
let f = Fixture::new();
f.old_cache();
assert!(
fetch_pngsuite_in(
&f.0,
"unused",
&fixture_manifest(),
|_| Err("offline".into()),
|_, _| panic!("failed download extracted"),
|_, _| panic!("failed download installed")
)
.unwrap_err()
.contains("offline")
);
f.assert_old_cache();
assert!(
fetch_pngsuite_in(
&f.0,
&crate::sha256::hex(FIXTURE_ARCHIVE),
&fixture_manifest(),
fixture_download,
|_, files| {
std::fs::write(files.join("partial.png"), b"partial").unwrap();
Err("extract failed".into())
},
|_, _| panic!("failed extraction installed")
)
.unwrap_err()
.contains("extract failed")
);
f.assert_old_cache();
for extra in [false, true] {
let e = fetch_pngsuite_in(
&f.0,
&crate::sha256::hex(FIXTURE_ARCHIVE),
&fixture_manifest(),
fixture_download,
|a, files| {
fixture_extract(a, files)?;
if extra {
std::fs::write(files.join("extra.png"), b"extra").unwrap();
} else {
std::fs::remove_file(files.join("a.png")).unwrap();
}
Ok(())
},
|_, _| panic!("unverified extraction installed"),
)
.unwrap_err();
assert!(e.contains(if extra { "unexpected file" } else { "missing files" }));
f.assert_old_cache();
}
}
#[test]
fn pngsuite_failed_installation_restores_old_cache() {
for fail_backup in [false, true] {
let f = Fixture::new();
f.old_cache();
let e = fetch_pngsuite_in(&f.0, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| {
if (fail_backup && from == f.cache()) || (!fail_backup && from.file_name().is_some_and(|n| n == "files")) {
Err(std::io::Error::other("injected rename failure"))
} else {
std::fs::rename(from, to)
}
})
.unwrap_err();
assert!(e.contains("injected rename failure"));
f.assert_old_cache();
}
}
#[test]
fn pngsuite_failed_rollback_retains_and_names_the_backup() {
let f = Fixture::new();
f.old_cache();
let e = fetch_pngsuite_in(&f.0, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| {
if from == f.cache() { std::fs::rename(from, to) } else { Err(std::io::Error::other("injected install and rollback failure")) }
})
.unwrap_err();
let stage = std::fs::read_dir(&f.0).unwrap().next().unwrap().unwrap().path();
let backup = stage.join("previous");
assert!(e.contains(&backup.display().to_string()), "{e}");
assert_eq!(std::fs::read(backup.join("old.png")).unwrap(), b"old cache bytes");
std::fs::rename(backup, f.cache()).unwrap();
}
#[test]
fn pngsuite_reads_are_bounded_and_malformed_manifests_are_rejected() {
let f = Fixture::new();
let file = f.0.join("large");
std::fs::File::create(&file).unwrap().set_len(PNGSUITE_MAX_ARCHIVE_BYTES + 1).unwrap();
assert!(pngsuite_bytes(&file, PNGSUITE_MAX_ARCHIVE_BYTES).unwrap_err().contains("regular file"));
for malformed in ["", "invalid", "00 a.png", &format!("{} ../outside\n", crate::sha256::hex(b""))] {
assert!(verify_pngsuite(&f.0, malformed).is_err());
}
}
#[test]
fn pngsuite_download_copy_accepts_boundary_and_never_writes_excess() {
let limit = PNGSUITE_MAX_ARCHIVE_BYTES as usize;
for len in [limit - 1, limit, limit + 1, limit * 2] {
let data = vec![0xa5; len];
let mut out = Vec::new();
let result = copy_pngsuite_download(&mut std::io::Cursor::new(&data), &mut out);
assert_eq!(result.is_ok(), len <= limit);
assert_eq!(out, data[..len.min(limit)]);
}
}
// The ordinary test invocation returns immediately. Only this test's child command
// enables emission; it stays alive so cleanup must kill and reap that particular child.
#[test]
fn pngsuite_stream_fixture() {
if std::env::var_os("PHOTOCRAFT_PNGSUITE_STREAM_TEST").is_some() {
let mut out = std::io::stdout().lock();
for _ in 0..256 {
let _ = out.write_all(&[0xa5; 8192]);
}
std::thread::park();
}
}
fn stream_child() -> Child {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "corpus::tests::pngsuite_stream_fixture", "--nocapture"])
.env("PHOTOCRAFT_PNGSUITE_STREAM_TEST", "1")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap()
}
#[test]
fn pngsuite_oversize_and_io_failures_kill_and_reap_only_the_download_child() {
struct FailedRead;
impl Read for FailedRead {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("injected read failure"))
}
}
struct FailedWrite;
impl Write for FailedWrite {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("injected write failure"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut child = stream_child();
let stdout = child.stdout.take().unwrap();
let mut out = Vec::new();
assert!(finish_pngsuite_download(&mut child, stdout, &mut out).unwrap_err().contains("archive exceeds"));
assert_eq!(out.len() as u64, PNGSUITE_MAX_ARCHIVE_BYTES);
assert!(child.try_wait().unwrap().is_some());
let mut child = stream_child();
assert!(child.try_wait().unwrap().is_none());
assert!(finish_pngsuite_download(&mut child, FailedRead, &mut Vec::new()).unwrap_err().contains("injected read failure"));
assert!(child.try_wait().unwrap().is_some());
let mut child = stream_child();
let stdout = child.stdout.take().unwrap();
assert!(finish_pngsuite_download(&mut child, stdout, &mut FailedWrite).unwrap_err().contains("injected write failure"));
assert!(child.try_wait().unwrap().is_some());
}
#[cfg(unix)]
#[test]
fn pngsuite_cache_rejects_symlinks_even_when_target_bytes_match() {
let f = Fixture::new();
std::fs::create_dir(f.cache()).unwrap();
fixture_extract(Path::new("unused"), &f.cache()).unwrap();
std::fs::write(f.0.join("target"), b"alpha").unwrap();
std::fs::remove_file(f.cache().join("a.png")).unwrap();
std::os::unix::fs::symlink(f.0.join("target"), f.cache().join("a.png")).unwrap();
assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("regular file"));
}
}

View File

@ -40,6 +40,10 @@ pub const ASSET_STORE_TEMPLATE_COMMIT: &str = "f671981ee89d15526285b3cc87b4393d3
pub const AFFINITY_SAMPLES_COMMIT: &str = "96aca6bd86bbe7a677f1cf0630bd832d23943be8";
/// PngSuite (public domain), a fixed release archive.
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
/// Fixed archive, corroborated by HTTPS records in opencodecs at 6f070029 and rivet-png at
/// 134faad8 (corpus/checksums.json and tests/pngsuite/SHA256SUMS). Check the legacy HTTP
/// download against this committed digest before extraction.
pub const PNGSUITE_SHA256: &str = "0294b244c95a8342c01b00010cf34abdcabc7c6a34fd0fe1bd963917537bfdc8";
/// `corpus/photoshop`: 258 PSDs authored with Photoshop by the photocraft-corpus generator.
pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus {

View File

@ -579,6 +579,8 @@ mod tests {
("el", "el.tsv"),
("uk", "uk.tsv"),
("nl", "nl.tsv"),
("hu", "hu.tsv"),
("tr", "tr.tsv"),
("vi", "vi.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);