Merge current main fixes and validate craft-print rollout
This commit is contained in:
commit
a301e31864
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
27
Cargo.toml
27
Cargo.toml
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ target/
|
|||
corpus/
|
||||
artifacts/
|
||||
Cargo.lock
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ target
|
|||
corpus
|
||||
artifacts
|
||||
coverage
|
||||
Cargo.lock
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
Cargo.lock
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 %");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue