Merge main into fix/history-options

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-12 19:09:00 +02:00
co-authored by Claude Opus 5.5
129 changed files with 11518 additions and 334 deletions
+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+2
View File
@@ -19,7 +19,9 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
Generated
+33 -31
View File
@@ -1743,6 +1743,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"num-complex",
"pulp",
"rayon-core",
"smallvec",
"zune-inflate",
]
@@ -2331,9 +2332,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",
]
@@ -4182,7 +4183,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"arboard",
"async-io",
@@ -4224,7 +4225,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"crc32fast",
"flate2",
@@ -4234,7 +4235,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4247,7 +4248,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"base64",
"cap-std",
@@ -4269,7 +4270,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4283,7 +4284,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"moxcms",
"rayon",
@@ -4291,7 +4292,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"exr",
"flate2",
@@ -4312,7 +4313,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-cms",
"serde",
@@ -4320,7 +4321,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"criterion",
"photocraft-color",
@@ -4335,7 +4336,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4346,7 +4347,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"blake3",
"exr",
@@ -4380,7 +4381,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"blake3",
"crc32fast",
@@ -4400,14 +4401,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"half",
"log",
@@ -4424,14 +4425,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"blake3",
"crc32fast",
@@ -4460,14 +4461,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4478,7 +4479,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4493,7 +4494,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4504,7 +4505,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4514,7 +4515,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"flate2",
"image",
@@ -4529,7 +4530,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4541,7 +4542,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4552,7 +4553,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"flate2",
"parley",
@@ -4566,7 +4567,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"base64",
"craft-ui",
@@ -4574,6 +4575,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"epaint_default_fonts",
"flate2",
"log",
"photocraft-algo",
@@ -4605,7 +4607,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4616,7 +4618,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"eframe",
"egui",
@@ -7449,7 +7451,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"serde",
"serde_json",
+34 -16
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.8.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -50,7 +50,9 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
egui = "0.36"
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -81,13 +83,14 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -95,20 +98,35 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
# builds and the speed-oriented pixel-processing overrides below retain their settings.
[profile.wasm-release.package.photocraft-engine]
# 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.photocraft-io]
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.photocraft-web]
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
+7 -2
View File
@@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -308,7 +312,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
@@ -341,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
warn_all(err, &ws);
}
Ok(())
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+3 -2
View File
@@ -992,8 +992,9 @@ mod tests {
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
for c in 0..4 {
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
for c in 0..3 {
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
}
}
let cancel = || true;
+1 -1
View File
@@ -169,7 +169,7 @@ fn apply_inner(
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src);
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
}
if ctl.cancelled() {
return None;
+2 -2
View File
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src);
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src);
crate::mix_selection(&mut data, area, mask, &src, true);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
+1 -1
View File
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src);
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
}
let mut result = surface.clone();
result.write_region(area, &data);
+29 -4
View File
@@ -1095,7 +1095,7 @@ fn apply_tiled_with_impl(
return (*t, Vec::new());
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src);
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
@@ -1179,7 +1179,7 @@ fn apply_box_blur(
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
for (t, data) in v.iter_mut() {
if let Some(sel) = selection {
mix_selection(data, *t, sel, &Image::read(surface, *t));
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
}
s.write_region(*t, data);
}
@@ -1202,8 +1202,9 @@ fn apply_box_blur(
}
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
/// coverage (channel 0 of `sel`).
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
@@ -1211,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
@@ -1229,6 +1251,9 @@ mod tests_ext;
#[cfg(test)]
mod tests_mosaic;
#[cfg(test)]
mod tests_selection_mix;
/// The current upstream sampler through the identical source/selection/tile pipeline.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
+1 -1
View File
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
+1 -1
View File
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
};
let spans = Spans::new(radius, shape_inside(shape));
let mut expected = conv_spans(&src, area, &ctx, &spans);
crate::mix_selection(&mut expected, area, &selection, &src);
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
let mut reference = surface.clone();
reference.write_region(area, &expected);
for tile in [17, 256] {
+1 -11
View File
@@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
+1 -1
View File
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
use photocraft_color::{PixelFormat, SampleType};
fn selection(coverage: f32) -> Surface {
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
}
#[test]
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
let r = Rect::new(0, 0, 1, 1);
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [0.5, 1.0] {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
let mut p = vec![0.5; format.channels()];
*p.last_mut().unwrap() = alpha;
s.write_pixel(0, 0, &p);
let original = s.pixel(0, 0);
let a = format.channels() - 1;
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
let actual = out.pixel(0, 0);
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
for c in 0..a {
let before = original[c] * original[a] + 1.0 - original[a];
let after = actual[c] * actual[a] + 1.0 - actual[a];
assert!(
(before - after).abs() < tolerance,
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
);
}
}
}
}
}
}
#[test]
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
let r = Rect::new(0, 0, 2, 1);
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
for sample in SampleType::ALL {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 0);
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
}
}
#[test]
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
let r = Rect::new(0, 0, 3, 3);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
let full = apply(&s, &filter, r, r, None);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 1);
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
}
}
#[test]
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
let r = Rect::new(0, 0, 1, 1);
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
let filtered = vec![8.0, -4.0, 0.75, 1.0];
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(0.5), &original, true);
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
assert!((actual - expected).abs() < 1e-6, "{data:?}");
}
for coverage in [0.0, 1.0] {
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(coverage), &original, true);
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
}
}
#[test]
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
let r = Rect::new(0, 0, 1, 1);
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
for ch in [1, 3, 4] {
let original = Image { rect: r, ch, data: vec![0.25; ch] };
let mut data = vec![0.75; ch];
mix_selection(&mut data, r, &selection(0.25), &original, false);
assert_eq!(data, vec![0.375; ch]);
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
None => Image::read(&surface, t.inflate(radius)),
};
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+7
View File
@@ -296,6 +296,13 @@ impl Profile {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
+68 -1
View File
@@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() {
}
}
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
// Allow only float rounding at the physical L* limits, not a different printing condition.
let lightness = -1e-4..=100.0 + 1e-4;
paper.iter().chain(&black).all(|v| v.is_finite())
&& lightness.contains(&paper[0])
&& lightness.contains(&black[0])
&& paper[0] > 90.0
&& black[0] < paper[0]
}
#[test]
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
use photocraft_cms::Curve;
use photocraft_cms::pipeline::Stage;
use photocraft_cms::profile::{Lut, LutKind};
let mut p = cmyk().clone();
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
// the inherited coated profile's inverse is not an inverse of this generated model.
p.a2b = [None, None, None];
p.b2a = [None, None, None];
p.a2b[1] = Some(Lut {
kind: LutKind::Ab,
inputs: 4,
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
// Explicit expected values, not the validation predicate: also catch wrong channels,
// intent selection, constant output and incorrect LUT encoding/interpolation.
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
}
}
#[test]
fn cmyk_endpoints_reject_invalid_values() {
let paper = [100.0, 0.0, 0.0];
let black = [48.0, 0.0, 0.0];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for channel in 0..3 {
let mut p = paper;
let mut k = black;
p[channel] = bad;
k[channel] = bad;
assert!(!cmyk_endpoints_valid(p, black));
assert!(!cmyk_endpoints_valid(paper, k));
}
}
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
}
}
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
/// converts through each.
#[test]
@@ -500,7 +567,7 @@ fn system_profiles() {
// Real CMYK profile: paper lighter than 100 % K.
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
// And it round-trips through its own BToA.
if p.b2a[1].is_some() {
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
+3 -1
View File
@@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
thiserror.workspace = true
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
png = "0.18"
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
exr = { version = "1.73", default-features = false }
half = "2"
serde_json.workspace = true
@@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
exr = { version = "1.73", default-features = false, features = ["rayon"] }
[dev-dependencies]
proptest.workspace = true
+3
View File
@@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
`photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
+1 -1
View File
@@ -16,7 +16,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.1"
heic-rs = "=0.1.4"
[profile.release]
debug = 1
+26 -7
View File
@@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
/// after checking every part's declared size against the limits, before any pixel allocation.
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
let mut total: u64 = 0;
@@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
best
}
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
/// return the requested part's layer.
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let image =
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
/// is asked about the headers in file order and the first `Ok` wins, so every header before
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
/// then decompressed and only its planes allocated; the other parts are skipped unread.
struct OnlyPart<C> {
inner: C,
index: usize,
seen: std::cell::Cell<usize>,
}
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
type Reader = C::Reader;
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
let position = self.seen.get();
self.seen.set(position.saturating_add(1));
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
}
}
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
}
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
+3 -9
View File
@@ -11,7 +11,6 @@ use std::io::Cursor;
use exr::meta::MetaData;
use exr::meta::attribute::AttributeValue;
use exr::prelude::{ReadChannels, ReadLayers, read};
use crate::Format;
use crate::error::CodecError;
@@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
.iter()
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
// Also caps all parts' declared sizes together, like every other flat decode.
let meta = super::exr::read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
}
@@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
limits.check_bytes(w32, h32, per_pixel)?;
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|e| match e {
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
e => CodecError::malformed(F, e),
},
)?;
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
+5
View File
@@ -32,6 +32,11 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
}
}
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
+5
View File
@@ -98,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
finish_decode(img, o, opts)
}
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
}
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
+29
View File
@@ -197,3 +197,32 @@ fn parts_are_capped_together() {
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
assert!(e.contains("across 3 parts"), "{e}");
}
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
/// inflating it fails.
fn file_with_corrupt_first_part() -> Vec<u8> {
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
let mut cursor = std::io::Cursor::new(&bytes[..]);
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
let table = cursor.position() as usize;
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
for i in 0..4 {
let at = table + i * 8;
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
bytes[offset + 12..offset + 12 + size].fill(0xA5);
}
bytes
}
#[test]
fn decoding_one_part_does_not_decode_the_others() {
let bytes = file_with_corrupt_first_part();
// Part 0 is unreadable ...
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
// ... but asking for part 1 must not touch it.
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}
+55
View File
@@ -1,3 +1,6 @@
mod common;
use common::tiffgen::*;
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
fn classic_white_is_zero() -> Vec<u8> {
@@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
assert_eq!(image.data(), &upright, "orientation {orientation}");
}
}
fn with_compression(mut dir: Dir, code: u64) -> Dir {
for entry in &mut dir.entries {
if entry.0 == 259 {
entry.2 = vec![V::N(code)];
}
}
dir
}
#[test]
fn jpeg_compressed_tiff_decodes() {
// A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7).
let mut jpeg = Vec::new();
let gray = vec![100u8; 16 * 16];
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
dir.chunks = vec![jpeg];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode");
assert_eq!(image.dimensions(), (16, 16));
assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4));
}
#[test]
fn ccitt_group4_tiff_decodes() {
// 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB.
let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001";
let mut data = Vec::new();
for chunk in bits.as_bytes().chunks(8) {
let mut byte = 0u8;
for (i, c) in chunk.iter().enumerate() {
if *c == b'1' {
byte |= 0x80 >> i;
}
}
data.push(byte);
}
let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4);
dir.chunks = vec![data];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a CCITT G4 TIFF should decode");
assert_eq!(image.dimensions(), (8, 3));
let d = image.data();
assert_eq!(d.len(), 24, "{d:?}");
assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}");
assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}");
assert_ne!(d[0], d[8], "{d:?}");
assert_eq!(d[0], 255, "white row of a WhiteIsZero image");
}
+11 -1
View File
@@ -233,6 +233,11 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
///
/// Fully transparent pixels are written as the default pixel. Converting transparent black
/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would
/// otherwise count as content: the result would span the whole canvas.
///
/// A CMYK document of plain Normal layers is composited in its ink values instead
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
@@ -249,6 +254,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
}
let mut s = Surface::new(fmt);
let n = fmt.channels();
let empty = s.default_pixel();
let mut data = Vec::new();
// The composite separates back to CMYK through the document's own profile, the one its
// pixels were read through.
@@ -262,7 +268,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
data.resize(band.px.len() * n, 0.0);
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
photocraft_raster::from_rgba_into(&fmt, *p, out);
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
out.copy_from_slice(&empty);
} else {
photocraft_raster::from_rgba_into(&fmt, *p, out);
}
}
});
s.write_region(band.rect, &data);
+20 -4
View File
@@ -45,7 +45,7 @@ impl<'a> PreparedPatterns<'a> {
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
if p.is_empty() {
if !p.is_paintable() {
return None;
}
let bytes = usize::try_from(p.width)
@@ -87,9 +87,13 @@ impl<'a> PreparedPatterns<'a> {
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
/// pattern in colour in its picker, filled in grey).
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)];
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
@@ -117,8 +121,9 @@ impl Tile {
}
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
if p.is_empty() {
if !p.is_paintable() {
return None;
}
let (w, h) = (p.width as usize, p.height as usize);
@@ -255,6 +260,17 @@ mod tests {
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
}
#[test]
fn patterns_too_large_to_paint_are_skipped_without_allocating() {
// Only the size is declared (no tiles): a hostile file can claim any size.
let p = Pattern { id: "big".into(), name: "Big".into(), width: 9000, height: 8000, surface: Surface::new(PixelFormat::RGBA8) };
assert!(!p.is_paintable());
assert!(Tile::new(&p).is_none());
assert!(pattern_rgba(&p, ColorMode::Rgb).is_empty());
let list = [p];
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
}
#[test]
fn link_phase_scale_and_angle_move_the_origin() {
let t = Tile::new(&checker()).unwrap();
+28
View File
@@ -604,6 +604,34 @@ fn cmyk_document_renders_via_rgb() {
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
}
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
/// surface must still treat them as empty, or its content spans the whole canvas.
#[test]
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
let modes = [
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
];
for (mode, samples) in modes {
for &sample in samples {
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
let fmt = PixelFormat::new(mode, sample, true);
let mut l = Layer::raster("dot", fmt);
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
d.layers.push(l);
let s = flatten_to_surface(&d, fmt, None);
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
// Over a background everything is content, as before.
let fmt_opaque = PixelFormat::new(mode, sample, false);
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
}
}
}
#[test]
fn sixteen_bit_and_float_layers_composite() {
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
+17
View File
@@ -5,6 +5,13 @@
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
pub const MAX_EDGE: u32 = 30_000;
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
/// (depth and colour model are the pattern's own, as in Photoshop).
#[derive(Clone, Debug, PartialEq)]
@@ -57,6 +64,16 @@ impl Pattern {
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
/// Pixels in the tile (`width × height`, never overflowing).
pub fn pixel_count(&self) -> u64 {
u64::from(self.width) * u64::from(self.height)
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
pub fn is_paintable(&self) -> bool {
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
}
}
/// Display form of a stored pattern name (see [`Pattern::display_name`]).
+47 -1
View File
@@ -2,11 +2,15 @@
//! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush
//! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound,
//! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush.
//!
//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean
//! other things with other tools, so the shell gives them to it only for the painting tools
//! (`move_mods::arrow_keys`).
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always};
use crate::{Result, Session};
use crate::{EngineError, Result, Session};
/// Photoshop's largest brush diameter, in pixels.
pub const MAX_SIZE: f32 = 5000.0;
@@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result<Value> {
crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } }))
}
/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts),
/// wrapped into Photoshop's -180..=180.
fn turned(angle: f32, degrees: f32) -> f32 {
let a = (angle + degrees).rem_euclid(360.0);
if a > 180.0 { a - 360.0 } else { a }
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let by = p
.get("degrees")
.and_then(Value::as_f64)
.filter(|d| d.is_finite() && d.abs() <= 360.0)
.ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?;
let angle = turned(s.tools.brush.angle, by as f32);
crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } }))
}
macro_rules! key_cmd {
($id:literal, $label:literal, $sc:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true }
@@ -47,6 +68,16 @@ pub fn specs() -> Vec<CommandSpec> {
key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)),
key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)),
key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)),
CommandSpec {
id: "tools.rotateBrush",
label: "Rotate Brush Tip",
menu: &[],
shortcut: None,
params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##,
enabled: always,
run: rotate,
journal: true,
},
]
}
@@ -74,6 +105,21 @@ mod tests {
assert_eq!(hardness_step(0.0, false), 0.0);
}
#[test]
fn rotate_brush_turns_the_tip_and_wraps_at_180() {
assert_eq!(turned(0.0, 1.0), 1.0);
assert_eq!(turned(179.0, 15.0), -166.0);
assert_eq!(turned(-175.0, -15.0), 170.0);
assert_eq!(turned(180.0, 0.0), 180.0);
let mut s = Session::new();
s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap();
assert_eq!(s.tools.brush.angle, -15.0);
for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] {
assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.tools.brush.angle, -15.0);
}
#[test]
fn commands_step_the_session_brush_and_journal() {
let mut s = Session::new();
+4
View File
@@ -1126,11 +1126,15 @@ fn delete_channel(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "missing `channel`")),
},
};
// `edit` validates the view against the shorter channel list before we can remap it.
// Keep the original indices and eyes so a surviving final channel is not discarded.
let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone();
s.edit("Delete Channel", |doc, _| {
doc.channels.remove(i);
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.channel_view = view;
let v = &mut st.channel_view;
if i < v.alpha_visible.len() {
v.alpha_visible.remove(i);
+50
View File
@@ -168,6 +168,56 @@ fn channel_management_and_view_state() {
assert_eq!(doc(&s).channels.len(), 3);
}
#[test]
fn deleting_an_earlier_channel_keeps_the_last_channel_targeted() {
for depth in [8, 16, 32] {
let mut s = session_depth(depth);
s.execute("channel.new", json!({"name": "Remove", "fill": "black"})).unwrap();
s.execute("channel.new", json!({"name": "Keep", "fill": "white"})).unwrap();
let layer_before = px(&s, 5, 5);
s.execute("channel.delete", json!({"channel": "Remove"})).unwrap();
assert_eq!(doc(&s).channels[0].name, "Keep");
let view = &s.active().unwrap().channel_view;
assert_eq!(view.target, ChannelTarget::Alpha(0), "the surviving target follows its new index");
assert!(view.alpha_shown(0));
assert_eq!(view.visible_colors(3), 0, "keep the channel-only view");
s.execute("image.adjustments.invert", json!({})).unwrap();
assert_eq!(chan(&s, 0, 5, 5), 0.0, "implicit pixel edits still target Keep");
assert_eq!(px(&s, 5, 5), layer_before, "the layer must not be edited instead");
assert!(s.undo());
assert_eq!(chan(&s, 0, 5, 5), 1.0);
assert_eq!(px(&s, 5, 5), layer_before);
assert!(s.redo());
assert_eq!(chan(&s, 0, 5, 5), 0.0);
assert_eq!(px(&s, 5, 5), layer_before);
}
}
#[test]
fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() {
for target_index in 0..3 {
let mut s = session();
for name in ["First", "Middle", "Last"] {
s.execute("channel.new", json!({"name": name})).unwrap();
}
s.execute("channel.target", json!({"channel": target_index})).unwrap();
for (channel, visible) in [false, false, true].into_iter().enumerate() {
s.execute("channel.setVisible", json!({"channel": channel, "visible": visible})).unwrap();
}
s.execute("channel.delete", json!({"channel": "Middle"})).unwrap();
let view = &s.active().unwrap().channel_view;
let expected = match target_index {
0 => ChannelTarget::Alpha(0),
1 => ChannelTarget::Composite,
_ => ChannelTarget::Alpha(1),
};
assert_eq!(view.target, expected, "old target {target_index}");
assert!(!view.alpha_shown(0));
assert!(view.alpha_shown(1), "Last keeps its eye when Middle is removed");
}
}
#[test]
fn every_colour_channel_can_hide_while_alpha_stays() {
let mut s = session();
+59 -3
View File
@@ -276,18 +276,22 @@ impl ColorState {
if !mismatch || linear_hdr {
return merge(base, json!({"action": "kept"}));
}
// An input-only profile can't be the document's space: nothing could be
// composited into it (#3876). Preserve converts it to the working space instead.
let input_only = !emb.can_be_destination();
let action = match policy {
Policy::Preserve => "kept",
Policy::Preserve if !input_only => "kept",
Policy::Off => {
doc.icc_profile = None;
"discarded"
}
Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Ok(()) => "converted",
Err(_) => "kept",
},
};
merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}))
let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}));
if input_only { merge(report, json!({"inputOnly": true})) } else { report }
}
None => {
// Untagged files are assumed to be in the working space; tag them when it is not
@@ -1397,6 +1401,58 @@ mod settings_tests {
assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3"));
}
/// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing
/// Profile embedded in iPhone photos.
fn input_only_p3() -> Profile {
let p3 = Builtin::DisplayP3.profile();
let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out));
let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]);
let lut = photocraft_cms::profile::Lut {
kind: photocraft_cms::profile::LutKind::Ab,
inputs: 3,
outputs: 3,
stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()],
};
let mut p = p3.clone();
p.class = photocraft_cms::ProfileClass::Input;
p.pcs = photocraft_cms::Pcs::Lab;
p.description = "Input-only P3".into();
(p.matrix, p.trc) = (None, None);
p.a2b = [Some(lut), None, None];
p.b2a = [None, None, None];
p.with_encoded_bytes()
}
#[test]
fn input_only_profiles_are_converted_on_open() {
// #3876: an iPhone photo's input-only profile was kept as the document's space, so
// compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags".
let input = input_only_p3();
assert!(Builtin::DisplayP3.profile().can_be_destination());
assert!(!input.can_be_destination());
let mut s = Session::new();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (photo, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true)));
let doc = &s.active().unwrap().doc;
assert!(desc(doc).unwrap().starts_with("sRGB"));
// Converted from P3, not reinterpreted as sRGB.
assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01);
let (other, _) = s.open_document(tagged("srgb"), None);
s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap();
// Off still discards the profile, and profiles that can be a destination are still kept.
s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (_, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true)));
s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap();
let (_, r) = s.open_document(tagged("display-p3"), None);
assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None));
}
/// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`.
fn exif_color_space(space: u16) -> Vec<u8> {
let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();
+38
View File
@@ -785,6 +785,13 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) {
return Err(EngineError::Other("Merge Group needs a group".into()));
}
merge_group(s, id)
}),
cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| {
s.edit("Flatten Image", |doc, active| {
let fmt = doc.pixel_format();
@@ -1431,6 +1438,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
Ok(dup)
}
/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they
/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the
/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask
/// and style are applied to the pixels.
fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
s.edit("Merge Group", |doc, active| {
let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
if !group.visible {
return Err(EngineError::Other("the group is hidden".into()));
}
let fmt = doc.pixel_format();
let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true);
let mut solo = group.clone();
solo.opacity = 1.0;
solo.blend = photocraft_doc::BlendMode::Normal;
solo.clipped = false;
let mut tmp = doc.clone();
tmp.layers = vec![solo];
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None)));
merged.id = group.id;
merged.opacity = group.opacity;
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
merged.clipped = group.clipped;
merged.locks = group.locks;
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged;
*active = Some(id);
Ok(())
})?;
Ok(json!({ "layer": id.0 }))
}
#[cfg(test)]
mod registry_tests {
use super::*;
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
"pattern" => {
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?;
// Photoshop's Fill uses the canvas origin.
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
out["pattern"] = json!(pat.id);
+6
View File
@@ -1217,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
fn merge_layers(s: &mut Session) -> Result<Value> {
let sel = selected(s);
if sel.len() < 2 {
// ⌘E on a lone group is Photoshop's Merge Group (#3832); on a layer, Merge Down.
if let Some(&id) = sel.first()
&& s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_)))
{
return s.execute("layer.mergeGroup", json!({ "layer": id.0 }));
}
return s.execute("layer.mergeDown", json!({}));
}
let mid = s.edit("Merge Layers", |doc, active| {
+1 -1
View File
@@ -55,7 +55,7 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
let pattern = if source == "pattern" {
let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?;
let tile = photocraft_compose::pattern::Tile::new(&pat)
.ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?;
.ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty or too large to paint".into() })?;
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
Some((tile, scale, angle, phase))
} else {
+20 -3
View File
@@ -326,8 +326,12 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
let pat = resolve_param(s, "brush.texturePattern", p)?;
let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten();
let Some(n) = n else {
return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint"));
};
// Brush textures are grey: luminance of the composite over white.
let mut px = vec![[0.0f32; 4]; (pat.width * pat.height) as usize];
let mut px = vec![[0.0f32; 4]; n];
pat.surface.read_rgba_into(pat.rect(), &mut px);
let lum: Vec<f32> = px
.iter()
@@ -348,8 +352,11 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
/// A swatch colour for UIs (average of the tile), straight RGB.
pub fn average_color(p: &Pattern) -> Color {
let mut px = vec![[0.0f32; 4]; (p.width * p.height) as usize];
p.surface.read_rgba_into(p.rect(), &mut px);
// A pattern too large to paint is averaged over its paintable top-left corner.
let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32;
let (w, h) = (p.width.min(side), p.height.min(side));
let mut px = vec![[0.0f32; 4]; w as usize * h as usize];
p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px);
let n = px.len().max(1) as f32;
let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]);
Color::rgb(s[0] / n, s[1] / n, s[2] / n)
@@ -560,4 +567,14 @@ mod tests {
assert!(s.tools.brush.texture.enabled);
assert!(matches!(s.tools.brush.texture.pattern, photocraft_paint::Pattern::Tile(_)));
}
#[test]
fn a_pattern_too_large_to_paint_is_refused_not_allocated() {
// width × height overflows u32: this panicked (debug) or wrapped (release).
let mut s = session(8);
let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) };
s.patterns.items.push(huge);
let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err();
assert!(e.to_string().contains("too large"), "{e}");
}
}
@@ -45,7 +45,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(CMD, "`points` is empty"));
};
let phase = stamp_phase(p, CMD, aligned, first)?;
let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty"))?;
let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty or too large to paint"))?;
reject_huge_stroke(CMD, &stroke)?;
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| {
+38
View File
@@ -1469,3 +1469,41 @@ fn delete_layer_preserves_an_unaffected_active_layer_and_last_layer_guard() {
assert_eq!(s.active().unwrap().active_layer, Some(other));
assert_eq!(s.active().unwrap().history.past_len(), before);
}
#[test]
fn merge_down_on_a_group_merges_the_group() {
// #3832: with a group selected, Merge Down is Photoshop's Merge Group (⌘E): the group's
// contents become one layer in its place, named after it, and the layer below is untouched.
let mut s = session_with_doc();
let below = s.execute("layer.new.layer", json!({"name": "Below"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 48})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 20, "y": 0, "width": 10, "height": 48})).unwrap();
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": a, "mode": "add"})).unwrap();
let group = s.execute("layer.groupLayers", json!({"name": "Badge"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": group, "opacity": 0.5})).ok();
let before = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)];
let layers = s.active().unwrap().doc.layer_count();
s.execute("layer.select", json!({"layer": group})).unwrap();
s.execute("layer.mergeLayers", json!({})).unwrap();
let st = s.active().unwrap();
let merged = st.doc.layer(st.active_layer.unwrap()).unwrap().clone();
assert_eq!(merged.name, "Badge");
assert!(matches!(merged.content, photocraft_doc::LayerContent::Raster(_)), "one pixel layer");
assert!(st.doc.layer(photocraft_doc::LayerId(below)).is_some(), "the layer below stays");
assert_eq!(st.doc.layer_count(), layers - 2, "group and its two layers became one");
let after = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)];
for (a, b) in before.iter().zip(&after) {
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{before:?} -> {after:?}");
}
// Undo brings the group back.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some());
}
@@ -0,0 +1,146 @@
//! Merging small layers gives a layer as big as their content in every colour mode. In CMYK and
//! Lab, transparent pixels used to keep the colour of converted transparent black, so Merge
//! Layers, Merge Visible, Stamp Visible and Stamp Down made layers that spanned the whole canvas:
//! Free Transform, the Move tool and alignment then worked on the canvas instead of the content.
use photocraft_doc::{Layer, LayerId};
use photocraft_engine::Session;
use photocraft_engine::transform_cmds::transform_bounds;
use photocraft_geom::Rect;
use serde_json::{Value, json};
const W: u32 = 300;
const H: u32 = 400;
/// Two small shapes far from the canvas edges, and the box around both.
const A: [i32; 4] = [40, 300, 20, 30];
const B: [i32; 4] = [90, 320, 25, 20];
const BOTH: Rect = Rect { x0: 40, y0: 300, x1: 115, y1: 340 };
/// Every mode at the depths it supports (Photoshop has no 32-bit CMYK or Lab).
const CASES: &[(&str, u32)] =
&[("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16), ("gray", 32), ("cmyk", 8), ("cmyk", 16), ("lab", 8), ("lab", 16)];
fn id_of(r: &Value) -> LayerId {
LayerId(r["layer"].as_u64().unwrap())
}
/// A transparent document with the two shapes on their own layers.
fn scene(mode: &str, depth: u32) -> (Session, LayerId, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "transparent"})).unwrap();
let a = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap());
let b = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": B, "fill": "#3060c0"})).unwrap());
(s, a, b)
}
fn select(s: &mut Session, ids: &[LayerId]) {
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
for id in &ids[1..] {
s.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap();
}
}
fn active(s: &Session) -> &Layer {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap()
}
/// The merged layer hugs the shapes (anti-aliased edges may add a pixel) and still shows them.
fn assert_tight(s: &Session, what: &str) {
let doc = &s.active().unwrap().doc;
let layer = active(s);
let b = transform_bounds(doc, layer);
let grown = Rect::new(BOTH.x0 - 1, BOTH.y0 - 1, BOTH.x1 + 1, BOTH.y1 + 1);
assert!(grown.contains_rect(&b) && b.contains_rect(&Rect::new(BOTH.x0 + 1, BOTH.y0 + 1, BOTH.x1 - 1, BOTH.y1 - 1)), "{what}: bounds {b:?}, want ~{BOTH:?}");
let surf = layer.surface().unwrap();
// Inside each shape: opaque. Between them and far away: transparent.
assert!(surf.rgba(A[0] + 5, A[1] + 5)[3] > 0.99, "{what}: first shape kept");
assert!(surf.rgba(B[0] + 5, B[1] + 5)[3] > 0.99, "{what}: second shape kept");
assert!(surf.rgba(70, 310)[3] < 0.01, "{what}: gap stays transparent");
assert!(surf.rgba(5, 5)[3] < 0.01, "{what}: corner stays transparent");
}
#[test]
fn merge_layers_keeps_content_bounds_in_every_mode() {
for &(mode, depth) in CASES {
let (mut s, a, b) = scene(mode, depth);
select(&mut s, &[a, b]);
s.execute("layer.mergeLayers", json!({})).unwrap();
assert_tight(&s, &format!("Merge Layers {mode} {depth}"));
}
}
#[test]
fn merge_visible_keeps_content_bounds_in_every_mode() {
for &(mode, depth) in CASES {
let (mut s, _, _) = scene(mode, depth);
s.execute("layer.mergeVisible", json!({})).unwrap();
assert_tight(&s, &format!("Merge Visible {mode} {depth}"));
}
}
#[test]
fn stamps_keep_content_bounds_in_every_mode() {
for &(mode, depth) in CASES {
let (mut s, _, _) = scene(mode, depth);
s.execute("layer.stampVisible", json!({})).unwrap();
assert_tight(&s, &format!("Stamp Visible {mode} {depth}"));
let (mut s, a, b) = scene(mode, depth);
select(&mut s, &[a, b]);
s.execute("layer.stampDown", json!({})).unwrap();
assert_tight(&s, &format!("Stamp Down {mode} {depth}"));
}
}
/// A fill layer may draw anywhere, so merging one renders the whole canvas; its mask still
/// limits the merged layer to the shapes.
#[test]
fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() {
let masked_scene = |mode: &str, depth: u32| {
let (mut s, a, _) = scene(mode, depth);
s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap();
let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap());
s.execute("select.deselect", json!({})).unwrap();
(s, a, fill)
};
for &(mode, depth) in CASES {
let (mut s, a, fill) = masked_scene(mode, depth);
select(&mut s, &[a, fill]);
s.execute("layer.mergeLayers", json!({})).unwrap();
assert_tight(&s, &format!("Merge Layers with a fill layer {mode} {depth}"));
let (mut s, _, _) = masked_scene(mode, depth);
s.execute("layer.mergeVisible", json!({})).unwrap();
assert_tight(&s, &format!("Merge Visible with a fill layer {mode} {depth}"));
}
}
/// Over an opaque Background, Merge Visible still makes an opaque, canvas-sized Background.
#[test]
fn merge_visible_onto_a_background_stays_opaque() {
for &(mode, depth) in CASES {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "white"})).unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap();
s.execute("layer.mergeVisible", json!({})).unwrap();
let what = format!("{mode} {depth}");
let doc = &s.active().unwrap().doc;
let layer = active(&s);
assert_eq!(layer.name, "Background", "{what}");
assert_eq!(transform_bounds(doc, layer), Rect::new(0, 0, W as i32, H as i32), "{what}");
let white = layer.surface().unwrap().rgba(5, 5);
assert!(white.iter().all(|c| *c > 0.98), "{what}: Background stays white, got {white:?}");
}
}
/// Undo restores both layers after the fixed merge.
#[test]
fn merge_layers_in_cmyk_is_one_undo_step() {
let (mut s, a, b) = scene("cmyk", 8);
select(&mut s, &[a, b]);
s.execute("layer.mergeLayers", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(doc.layer(a).is_some() && doc.layer(b).is_some());
}
+1
View File
@@ -2,3 +2,4 @@ target/
corpus/
artifacts/
Cargo.lock
coverage/
+9
View File
@@ -592,6 +592,15 @@ impl Loader<'_> {
}
let mut patterns = Vec::with_capacity(m.patterns.len());
for p in &m.patterns {
// Sizes come from the file: a pattern with no tiles can claim any size, and renderers
// allocate per pixel.
let max = photocraft_doc::pattern::MAX_EDGE;
if p.width == 0 || p.height == 0 {
return Err(FormatError::corrupt(format!("pattern `{}` is {}×{} px", p.name, p.width, p.height)));
}
if p.width > max || p.height > max {
return Err(FormatError::LimitExceeded(format!("pattern `{}` is {}×{} px (max {max} per side)", p.name, p.width, p.height)));
}
patterns.push(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface: self.surface(&p.surface)? });
}
let layer_comps = m.layer_comps.iter().map(|c| self.comp(c)).collect::<Result<Vec<_>>>()?;
+32 -33
View File
@@ -30,14 +30,24 @@ fn compress(data: &[u8]) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
}
fn decompress(data: &[u8], expected: usize, what: &str) -> Result<Vec<u8>> {
let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?;
let mut out = Vec::with_capacity(expected.min(64 << 20));
(&mut dec).take(expected as u64 + 1).read_to_end(&mut out).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?;
if out.len() != expected {
return Err(FormatError::corrupt(format!("{what}: expected {expected} bytes, got {}", out.len())));
/// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the
/// decoded length must be exactly that (`Corrupt` otherwise).
fn decompress_bounded(data: &[u8], max: usize, exact: Option<usize>, what: &str) -> Result<Vec<u8>> {
let zstd = |e: &dyn std::fmt::Display| FormatError::corrupt(format!("{what}: zstd: {e}"));
let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| zstd(&e))?;
let mut out = Vec::with_capacity(exact.unwrap_or(0).min(max).min(64 << 20));
// `max` may be `usize::MAX`: one byte past it must not overflow.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
(&mut dec).take(limit).read_to_end(&mut out).map_err(|e| zstd(&e))?;
match exact {
Some(n) if out.len() != n => Err(FormatError::corrupt(format!("{what}: expected {n} bytes, got {}", out.len()))),
None if out.len() > max => Err(FormatError::LimitExceeded(format!("{what} exceeds {max} bytes"))),
_ => Ok(out),
}
Ok(out)
}
fn decompress(data: &[u8], expected: usize, what: &str) -> Result<Vec<u8>> {
decompress_bounded(data, expected, Some(expected), what)
}
fn hash_bytes(b: &[u8]) -> Hash {
@@ -228,12 +238,7 @@ impl Fetch for LoadFetch<'_> {
let z = self.src.get(&path, self.opts.max_blob_bytes)?;
// Blob length is not in the manifest; the frame header bounds it and
// we cap at max_blob_bytes.
let mut dec = ruzstd::decoding::StreamingDecoder::new(&z[..]).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?;
let mut data = Vec::new();
(&mut dec).take(self.opts.max_blob_bytes as u64 + 1).read_to_end(&mut data).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?;
if data.len() > self.opts.max_blob_bytes {
return Err(FormatError::LimitExceeded(format!("{path} exceeds max_blob_bytes")));
}
let data = decompress_bounded(&z, self.opts.max_blob_bytes, None, &path)?;
self.account(data.len())?;
if hash_bytes(&data) != hash {
return Err(FormatError::corrupt(format!("{path}: content does not match its hash")));
@@ -313,13 +318,7 @@ impl Sink for Collect<'_> {
self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample));
return h;
}
let h = if cfg!(target_endian = "big") {
let mut b = tile.bytes().to_vec();
swap_to_le(&mut b, format.sample);
hash_bytes(&b)
} else {
hash_bytes(tile.bytes())
};
let h = hash_bytes(&tile_le(tile, format.sample));
if let Some(cache) = self.hash_cache.as_deref_mut() {
cache.insert(key, (Arc::downgrade(tile), h.clone()));
}
@@ -414,21 +413,23 @@ enum Object {
impl Object {
fn compressed(&self) -> Vec<u8> {
match self {
Object::Tile(t, s) => compress(&tile_le(t, *s)),
Object::Blob(b) => compress(b),
}
compress(&self.content())
}
fn matches_content_hash(&self, compressed: &[u8], what: &str) -> bool {
let expected = match self {
/// The bytes this object stores (tiles little-endian).
fn content(&self) -> std::borrow::Cow<'_, [u8]> {
match self {
Object::Tile(tile, sample) => tile_le(tile, *sample),
Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()),
};
decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(&expected))
}
}
}
/// Whether `compressed` decodes to exactly `expected`.
fn content_matches(expected: &[u8], compressed: &[u8], what: &str) -> bool {
decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(expected))
}
fn file_signature(metadata: &std::fs::Metadata) -> Option<FileSignature> {
#[cfg(unix)]
let unix = {
@@ -587,10 +588,8 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result<
}
let signature = file_signature(&metadata);
let expected_len = match object {
Object::Tile(tile, sample) => tile_le(tile, *sample).len(),
Object::Blob(blob) => blob.len(),
};
let expected = object.content();
let expected_len = expected.len();
// This is the same bound used by the tile loader. It comfortably includes
// zstd framing overhead while keeping damaged files bounded when inspected.
let max_compressed_len = expected_len.saturating_add(expected_len / 8).saturating_add(4096);
@@ -604,7 +603,7 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result<
if compressed.len() > max_compressed_len {
return Ok((false, None));
}
Ok((object.matches_content_hash(&compressed, what), signature))
Ok((content_matches(&expected, &compressed, what), signature))
}
/// A bundle path and its compressed bytes.
+19 -1
View File
@@ -201,7 +201,12 @@ impl<'a> ZipReader<'a> {
if e.uncompressed > max {
return Err(FormatError::LimitExceeded(format!("zip entry `{}` is {} bytes (max {max})", e.name, e.uncompressed)));
}
let raw = &self.bytes[e.data_offset..e.data_offset + e.compressed];
// `Entry` is public and cloneable: one from another archive must not index out of range.
let raw = e
.data_offset
.checked_add(e.compressed)
.and_then(|end| self.bytes.get(e.data_offset..end))
.ok_or_else(|| FormatError::corrupt(format!("zip entry `{}` out of range", e.name)))?;
let data = match e.method {
0 => raw.to_vec(),
8 => {
@@ -366,6 +371,19 @@ mod tests {
assert!(matches!(archive.read_by_name("manifest.json", usize::MAX), Err(FormatError::Corrupt(_))));
}
#[test]
fn an_entry_from_another_archive_is_an_error_not_a_panic() {
let mut big = ZipWriter::new();
big.add("a", &[7; 4096]).unwrap();
let big = big.finish().unwrap();
let foreign = ZipReader::new(&big).unwrap().entries[0].clone();
let mut small = ZipWriter::new();
small.add("a", b"x").unwrap();
let small = small.finish().unwrap();
let r = ZipReader::new(&small).unwrap();
assert!(matches!(r.read(&foreign, usize::MAX), Err(FormatError::Corrupt(_))));
}
#[test]
fn offset_access_rejects_overflow() {
assert!(u16_at(&[], usize::MAX).is_err());
+34
View File
@@ -419,6 +419,40 @@ fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() {
}
}
#[test]
fn an_unlimited_blob_budget_still_loads_blobs() {
// `max_blob_bytes + 1` overflowed (debug panic; release read zero bytes, failing every blob).
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
let opts = LoadOptions { max_blob_bytes: usize::MAX, ..LoadOptions::default() };
assert_eq!(load_from_bytes_with(&bytes, &opts).unwrap(), doc);
}
/// Rewrites the first pattern's declared size (its tiles stay as saved).
fn with_pattern_size(w: u64, h: u64) -> Vec<u8> {
rebuild(sample_cached(), |n, d| {
if n != "manifest.json" {
return Some(d);
}
let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap();
let p = &mut v["document"]["patterns"][0];
assert!(p.is_object(), "the sample has a pattern");
p["width"] = w.into();
p["height"] = h.into();
Some(serde_json::to_vec(&v).unwrap())
})
}
#[test]
fn pattern_sizes_from_the_file_are_checked() {
// A pattern can claim any size without tiles; painting it allocated per pixel (abort).
let huge = load_from_bytes(&with_pattern_size(65_535, 65_535));
assert!(matches!(huge, Err(FormatError::LimitExceeded(_))), "{huge:?}");
let empty = load_from_bytes(&with_pattern_size(0, 24));
assert!(matches!(empty, Err(FormatError::Corrupt(_))), "{empty:?}");
assert!(load_from_bytes(&with_pattern_size(32, 24)).is_ok());
}
/// A canvas is a few bytes of manifest JSON; one past the budget is refused before any work (#2935).
/// The default budget is the largest canvas the app creates (300000 × 300000, as `file.new`,
/// Canvas Size and PSB import allow), so everything a user can save reopens.
+1 -1
View File
@@ -389,7 +389,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
h = b.push(st);
}
if let Some(t) = &bv.texture
&& let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| !p.is_empty())
&& let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| p.is_paintable())
{
// Texture element (`effects::bevel_height`): luminance × depth / unit added.
let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) };
+3 -3
View File
@@ -803,7 +803,7 @@ impl<'a> Planner<'a> {
// compose::render_fill: the pattern tiled from the layer's frame (transparent
// when missing), then the layer's masks.
let empty = self.clear();
let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) else { return Ok(empty) };
let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) else { return Ok(empty) };
let frame = photocraft_compose::fill_frame(layer, self.cx.canvas);
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
@@ -1097,7 +1097,7 @@ impl<'a> Planner<'a> {
};
for &(_, e) in &rev {
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty())
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable())
{
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
l = self.paint(l, content, Cov::One, &paint, common.blend, common.opacity, F_GATE, clip, sb);
@@ -1312,7 +1312,7 @@ impl<'a> Planner<'a> {
match p {
FxPaint::Color(c) => Paint::Color(c.to_rgb()),
FxPaint::Gradient(g) => Paint::Gradient(g),
FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) {
FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) {
Some(pat) => Paint::Pattern(pat, placement(anchor, true, (0.0, 0.0), *scale, 0.0)),
None => Paint::None,
},
+2 -2
View File
@@ -9,11 +9,11 @@ rust-version.workspace = true
[dependencies]
# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files;
# moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests).
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
[lints]
workspace = true
+8
View File
@@ -20,7 +20,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
return import_tiff_page(name, bytes, None);
}
let img = codecs::decode(bytes)?;
// A palette BMP opens in Indexed Color mode with its colour table, as in Photoshop (#3802).
let bmp_palette = if codecs::detect(bytes) == Some(Format::Bmp) { codecs::bmp_palette(bytes).ok().flatten() } else { None };
let mut r = image_to_document(name, &img)?;
if let Some(colors) = bmp_palette.filter(|c| !c.is_empty())
&& r.document.mode == ColorMode::Rgb
{
r.document.mode = ColorMode::Indexed;
r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None });
}
r.document.file_encoding = file_encoding(bytes);
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
tga_alpha_to_channel(&mut r.document)?;
+1
View File
@@ -307,6 +307,7 @@ impl Ctx<'_> {
});
if let Some(txt2) = &self.txt2 {
photocraft_text::psd::apply_txt2(&mut t, &data, txt2);
photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2);
}
// Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and
// other writers leave type layers without image data (Photoshop re-renders them on
+43
View File
@@ -54,6 +54,49 @@ codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0);
codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0);
codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0);
fn indexed_bmp_8() -> Vec<u8> {
const FILE_HEADER_SIZE: usize = 14;
const INFO_HEADER_SIZE: usize = 40;
const PALETTE_ENTRY_SIZE: usize = 4;
const PALETTE: [[u8; 3]; 2] = [[10, 20, 30], [200, 100, 50]];
const PIXEL_ROW: [u8; 4] = [0, 1, 0, 0];
let pixel_offset = FILE_HEADER_SIZE + INFO_HEADER_SIZE + PALETTE.len() * PALETTE_ENTRY_SIZE;
let file_size = pixel_offset + PIXEL_ROW.len();
let mut bytes = Vec::with_capacity(file_size);
bytes.extend_from_slice(b"BM");
bytes.extend_from_slice(&(file_size as u32).to_le_bytes());
bytes.extend_from_slice(&[0; 4]);
bytes.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
bytes.extend_from_slice(&(INFO_HEADER_SIZE as u32).to_le_bytes());
bytes.extend_from_slice(&2i32.to_le_bytes());
bytes.extend_from_slice(&1i32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&8u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(PIXEL_ROW.len() as u32).to_le_bytes());
bytes.extend_from_slice(&0i32.to_le_bytes());
bytes.extend_from_slice(&0i32.to_le_bytes());
bytes.extend_from_slice(&(PALETTE.len() as u32).to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
for [r, g, b] in PALETTE {
bytes.extend_from_slice(&[b, g, r, 0]);
}
bytes.extend_from_slice(&PIXEL_ROW);
bytes
}
#[test]
fn indexed_bmp_opens_with_its_color_table() {
let doc = import("indexed.bmp", &indexed_bmp_8()).unwrap().document;
assert_eq!(doc.mode, ColorMode::Indexed);
assert_eq!(doc.depth, SampleType::U8);
assert_eq!(doc.color_table.as_ref().unwrap().colors.get(..2), Some(&[[10, 20, 30], [200, 100, 50]][..]));
let px = doc.layers[0].surface().unwrap().rgba(1, 0);
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6);
assert!((px[1] - 100.0 / 255.0).abs() < 1e-6);
assert!((px[2] - 50.0 / 255.0).abs() < 1e-6);
}
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
/// OBJECT, EXPTIME and the rest through an edit.
#[test]
+225
View File
@@ -0,0 +1,225 @@
//! #3795: a PSD type layer's alignment and character attributes can come from its style sheets
//! rather than its runs. A run's `Properties` / `StyleSheetData` hold only its overrides; the rest
//! comes from the paragraph style its `Name` names in `ParagraphSheetSet`, the character style in
//! `StyleSheetSet` (by name, or the paragraph's `DefaultStyleSheet`), then the Normal sheets.
//! Reading only the Normal sheets left a centred paragraph style read as Left, so starting an
//! edit re-laid the text out half a line to the side.
use photocraft_doc::text::TextAlign;
use photocraft_geom::Affine;
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D};
use photocraft_text::engine_data::Value as E;
use photocraft_text::psd::{TySh, text_layer_from_tysh, write_tysh};
fn real(v: f64) -> E {
E::Real(v)
}
fn d(kv: Vec<(&str, E)>) -> E {
E::Dict(kv.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
/// Photoshop-shaped EngineData for one paragraph of point text. `para` is the paragraph run's
/// `ParagraphSheet`, `style` the character run's `StyleSheet`; the sheet sets are the
/// `ResourceDict`'s, with Normal at index 0.
fn engine(text: &str, para: E, style: E, para_set: Vec<E>, style_set: Vec<E>) -> E {
let ed_text = format!("{text}\r");
let n = ed_text.encode_utf16().count() as i64;
let res = d(vec![
(
"FontSet",
E::Array(vec![d(vec![("Name", E::String("Inter-Regular".into())), ("Script", E::Int(0)), ("FontType", E::Int(1)), ("Synthetic", E::Int(0))])]),
),
("StyleSheetSet", E::Array(style_set)),
("ParagraphSheetSet", E::Array(para_set)),
("TheNormalStyleSheet", E::Int(0)),
("TheNormalParagraphSheet", E::Int(0)),
]);
d(vec![
(
"EngineDict",
d(vec![
("Editor", d(vec![("Text", E::String(ed_text))])),
("ParagraphRun", d(vec![("RunArray", E::Array(vec![d(vec![("ParagraphSheet", para)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])),
("StyleRun", d(vec![("RunArray", E::Array(vec![d(vec![("StyleSheet", style)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])),
(
"Rendered",
d(vec![(
"Shapes",
d(vec![(
"Children",
E::Array(vec![d(vec![
("ShapeType", E::Int(0)),
("Cookie", d(vec![("Photoshop", d(vec![("ShapeType", E::Int(0)), ("PointBase", E::Array(vec![real(0.0), real(0.0)]))]))])),
])]),
)]),
)]),
),
]),
),
("ResourceDict", res),
])
}
fn normal_char(size: f64) -> E {
d(vec![
("Name", E::String("Normal RGB".into())),
("StyleSheetData", d(vec![("Font", E::Int(0)), ("FontSize", real(size)), ("AutoLeading", E::Bool(true))])),
])
}
fn para_sheet(name: &str, justification: i64, default_style: i64) -> E {
d(vec![("Name", E::String(name.into())), ("DefaultStyleSheet", E::Int(default_style)), ("Properties", d(vec![("Justification", E::Int(justification))]))])
}
fn layer(text: &str, ed: &E) -> photocraft_doc::TextLayer {
let desc = Descriptor::new("TxLr")
.with("Txt ", D::Text(UnicodeString::new_nul(text)))
.with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") })
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(ed)));
let data = write_tysh(&TySh { transform: Affine::translate(450.0, 300.0), text: desc, warp: None, bounds: [0.0; 4] });
let mut t = text_layer_from_tysh(&data, 72.0).expect("parses");
// Import keeps the block, as for a layer read from a file.
t.psd_raw = Some(std::sync::Arc::new(data));
t
}
/// The paragraph run names a centred (or right-aligned) paragraph style and overrides nothing.
#[test]
fn alignment_from_the_paragraph_style_the_run_names() {
for (j, want) in [(2, TextAlign::Center), (1, TextAlign::Right)] {
let ed = engine(
"LOREM IPSUM",
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", j, 0)],
vec![normal_char(48.0)],
);
let t = layer("LOREM IPSUM", &ed);
assert_eq!(t.paragraphs[0].style.align, want, "Justification {j} from the paragraph style");
// Laid out around the anchor like Photoshop draws it.
let b = photocraft_text::TextEngine::new().layout(&t, 72.0).bounds().unwrap();
match want {
TextAlign::Center => assert!((b[0] + b[2]).abs() < 1.0, "centred on the anchor: {b:?}"),
_ => assert!(b[2].abs() < 1.0, "ends at the anchor: {b:?}"),
}
}
}
/// A run's own value still wins over its paragraph style, and an unknown style name or an
/// out-of-range sheet index falls back to Normal.
#[test]
fn run_overrides_win_and_bad_references_fall_back_to_normal() {
let ed = engine(
"Text",
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("Justification", E::Int(1))]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 0)],
vec![normal_char(48.0)],
);
assert_eq!(layer("Text", &ed).paragraphs[0].style.align, TextAlign::Right);
let ed = engine(
"Text",
d(vec![("Name", E::String("Missing".into())), ("DefaultStyleSheet", E::Int(9)), ("Properties", d(vec![]))]),
d(vec![("Name", E::String("Missing".into())), ("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 2, 0)],
vec![normal_char(30.0)],
);
let t = layer("Text", &ed);
assert_eq!(t.paragraphs[0].style.align, TextAlign::Center, "Normal's alignment");
assert!((t.runs[0].style.size_pt - 30.0).abs() < 0.01, "Normal's size: {}", t.runs[0].style.size_pt);
}
/// Character attributes from the paragraph's default character style (`DefaultStyleSheet`) and
/// from a character style the run names.
#[test]
fn character_attributes_from_the_style_sheets() {
let big = d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(72.0))]))]);
let ed = engine(
"Text",
d(vec![("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0)],
vec![normal_char(12.0), big.clone()],
);
assert!((layer("Text", &ed).runs[0].style.size_pt - 72.0).abs() < 0.01, "the paragraph's default character style");
let ed = engine(
"Text",
d(vec![("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]),
d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0)],
vec![normal_char(12.0), big],
);
let t = layer("Text", &ed);
assert!((t.runs[0].style.size_pt - 72.0).abs() < 0.01, "the named character style: {}", t.runs[0].style.size_pt);
// Other attributes still come from Normal.
assert_eq!(
t.runs[0].style.font_family,
layer(
"Text",
&engine(
"Text",
d(vec![("Properties", d(vec![]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0)],
vec![normal_char(12.0)]
)
)
.runs[0]
.style
.font_family
);
}
/// Export writes every run fully resolved and keeps the sheet sets: a round trip reads the same
/// alignment and size, with one entry per sheet.
#[test]
fn styles_from_sheets_survive_a_round_trip() {
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, Size};
let ed = engine(
"LOREM IPSUM",
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)],
vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])],
);
let mut t = layer("LOREM IPSUM", &ed);
let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = 72.0;
photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format());
doc.layers.push(Layer::new("t", LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center);
assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt);
}
/// A layer that inherits its alignment and size from sheets it no longer links to (a document
/// without those styles) keeps them through export: runs are written with what they inherited.
#[test]
fn unlinked_runs_keep_their_inherited_style_on_export() {
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, Size};
let ed = engine(
"LOREM IPSUM",
d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]),
d(vec![("StyleSheetData", d(vec![]))]),
vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)],
vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])],
);
let mut t = layer("LOREM IPSUM", &ed);
assert!(t.psd_raw.is_some());
let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = 72.0;
photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format());
doc.layers.push(Layer::new("t", LayerContent::Text(t)));
// No `text_styles_map::import`: the document has no styles and the runs link to none.
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center);
assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt);
}
+168 -3
View File
@@ -5,8 +5,8 @@
//! stores whole-document snapshots per transaction, which is simple, obviously correct, and the
//! same approach Photoshop's History panel exposes to users (one state per step).
//!
//! Memory is bounded by `max_states` plus an approximate byte budget. Only tiles *not shared*
//! with the current document count against it.
//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's
//! tiles and tiles only history holds count against it, including selection masks.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -295,7 +295,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| {
@@ -544,6 +544,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));
@@ -555,15 +634,100 @@ mod tests {
assert_eq!(counter.bytes_with(&empty), retained as u128);
}
fn video_document(depth: SampleType, frames: usize) -> Document {
let mut doc = Document::new("video", Size::new(64, 64), ColorMode::Rgb, depth);
let mut layer = Layer::raster("Video", doc.pixel_format());
let footage: Vec<_> = (0..frames)
.map(|i| {
let mut surface = photocraft_doc::Surface::new(doc.pixel_format());
surface.write_pixel(1, 1, &[i as f32 / frames as f32, 0.0, 0.0, 1.0]);
surface
})
.collect();
layer.content = photocraft_doc::LayerContent::Raster(footage[0].clone());
layer.video = Some(photocraft_doc::VideoData::new(footage, 24.0));
doc.layers.push(layer);
doc
}
#[test]
fn video_memory_counts_originals_and_deduplicates_displayed_frames() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = video_document(depth, 3);
let tile = doc.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
let video = doc.layers[0].video.as_mut().unwrap();
video.original_frames = Some(video.frames.clone());
// Original footage and the displayed frame alias the stack, except this painted frame.
video.frames[2].write_pixel(1, 1, &[0.0, 1.0, 0.0, 1.0]);
let mut cur = Arc::new(doc);
let mut h = History { max_bytes: usize::MAX, ..History::default() };
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
for name in ["A", "B"] {
edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into());
h.trim(&cur);
}
assert_counter(&h, &cur);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_eq!(h.unique_bytes(&cur), 0);
// Rasterizing keeps the displayed frame; the rest of the clip survives only in undo.
edit(&mut h, &mut cur, "Rasterize", |doc| doc.layers[0].video = None);
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_eq!(h.pixel_bytes(&cur), 4 * tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_counter(&h, &cur);
h.clear();
assert_eq!(h.pixel_bytes(&cur), tile);
}
}
#[test]
fn video_memory_budget_evicts_deleted_off_playhead_frames() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut cur = Arc::new(video_document(depth, 6));
let tile = cur.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len();
let mut h = History::default();
for _ in 0..4 {
edit(&mut h, &mut cur, "Delete Frame", |doc| {
doc.layers[0].video.as_mut().unwrap().frames.remove(1);
});
}
assert_eq!(h.pixel_bytes(&cur), 6 * tile);
assert_eq!(h.unique_bytes(&cur), 4 * tile);
h.max_bytes = 3 * tile;
assert_eq!(h.trim(&cur), 3);
assert_eq!(h.past_len(), 1);
assert_eq!(h.pixel_bytes(&cur), 3 * tile);
assert_eq!(h.unique_bytes(&cur), tile);
assert_counter(&h, &cur);
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 3);
assert_counter(&h, &cur);
cur = h.redo(cur).unwrap().0;
assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 2);
assert_counter(&h, &cur);
// A budget below the remaining clip still keeps the latest step undoable.
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 0);
assert!(h.can_undo());
}
}
#[test]
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 {
@@ -600,6 +764,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);
+10
View File
@@ -19,10 +19,20 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) {
if let Some(mask) = &layer.mask {
visit(&mask.surface);
}
if let Some(video) = &layer.video {
// Undo retains the whole clip, not only the displayed frame. Visit aliases too:
// Tiles tracks every owner and counts their shared allocations only once.
for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) {
visit(frame);
}
}
}
for channel in doc.channels.iter().chain(&doc.quick_mask) {
visit(&channel.surface);
}
if let Some(selection) = &doc.selection {
visit(selection);
}
}
impl Tiles {
+1
View File
@@ -2,3 +2,4 @@ target
corpus
artifacts
coverage
Cargo.lock
+5
View File
@@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock
+5 -1
View File
@@ -702,7 +702,11 @@ impl Layouter {
Some(b) => b + leading + pending_space,
};
pending_space = 0.0;
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline + descent > box_rect.1 + box_rect.3 + 0.5 };
// Photoshop keeps a horizontal line whose baseline is inside the box and lets
// its descenders hang below the bottom edge (#1317: three 35 pt lines in a
// 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped
// that line.
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline > box_rect.1 + box_rect.3 + 0.5 };
if is_box && overflow {
stop = true;
break;
+173 -30
View File
@@ -12,7 +12,7 @@
use photocraft_color::{Color, ColorMode};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp};
use photocraft_geom::Affine;
use photocraft_geom::{Affine, Point};
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor};
use crate::engine_data::{self as ed, Value as E};
@@ -212,6 +212,83 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
layer.sync_summary();
}
/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it.
fn txt2_frame(txt2: &E, index: usize) -> Option<&E> {
let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?;
let frame =
object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?;
txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0")
}
/// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved
/// (#1317). Recent Photoshop keeps a type layer's position partly in its `Txt2` frame: a matrix
/// (`/2 /2`) from the frame's space to the `TySh` text space and, for point text, the anchor
/// (`/0`), while EngineData's `PointBase` stays at the origin and `BoxBounds` stays in the
/// frame's space. Ignoring them put the text far from Photoshop's pixels (off the canvas, so
/// it "disappeared" as soon as an edit started), and point text a line too high and off its
/// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as
/// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`.
pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
let Some(t) = parse_tysh(tysh) else { return };
let Some(index) = (match t.text.get("TextIndex") {
Some(D::Integer(i)) => usize::try_from(*i).ok(),
_ => None,
}) else {
return;
};
let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str);
if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) {
return;
}
let Some(frame) = txt2_frame(txt2, index) else { return };
let m = arr_f(frame.path(&["2", "2"]));
let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY };
let at = match layer.shape {
TextShape::Point => {
let p = arr_f(frame.get("0"));
if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY }
}
TextShape::Box { .. } => Affine::IDENTITY,
};
let to_text = matrix.mul(&at);
if to_text == Affine::IDENTITY || to_text.determinant().abs() <= 1e-9 {
return;
}
// The frame's origin (point text: the anchor; box text: the box's top left) must land in
// the text-space `bounds` Photoshop wrote beside it, and not already be there without it. A `TySh` PhotoCraft rewrote (an edited
// or moved layer whose text is unchanged keeps its old `Txt2` object and frame) already has
// the frame folded into its transform and its `bounds` near the origin: applying the frame
// again would move it twice.
let origin = match layer.shape {
TextShape::Point => Point::new(0.0, 0.0),
TextShape::Box { .. } => {
let b = engine_data(&t.text)
.map(|e| {
arr_f(
e.path(&["EngineDict", "Rendered", "Shapes", "Children"])
.and_then(E::as_array)
.and_then(|c| c.first())
.and_then(|s| s.path(&["Cookie", "Photoshop", "BoxBounds"])),
)
})
.unwrap_or_default();
let [x, y, ..] = b[..] else { return };
Point::new(x, y)
}
};
let Some([l, top, r, bottom]) = descriptor_rect(&t.text, "bounds") else { return };
let inside = |p: Point| p.x >= l - 1.0 && p.x <= r + 1.0 && p.y >= top - 1.0 && p.y <= bottom + 1.0;
if inside(origin) || !inside(to_text.apply(origin)) {
return;
}
{
// From the file's own transform: the frame's anchor is exact, so it replaces the
// ink-box estimate `legacy_point_origin` made for such point text (#1469), which put
// the baseline at the ink's top and the anchor at the ink's left whatever the alignment.
layer.transform = t.transform.mul(&to_text);
}
}
/// OpenType settings of a `Txt2` style that are on/off flags, by style key, as Photoshop
/// writes them (each turned on alone in the Character panel's OpenType settings and read back):
/// `/20` Contextual Alternates, `/23` Fractions, `/24` Ordinals, `/25` Swash, `/26` Titling
@@ -380,46 +457,97 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
.and_then(E::as_array)
.map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect())
.unwrap_or_default();
let default_sheet = res.and_then(|r| r.get("TheNormalStyleSheet")).and_then(E::as_i64).unwrap_or(0) as usize;
let base_style =
res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).and_then(|a| a.get(default_sheet).or(a.first())).and_then(|s| s.get("StyleSheetData"));
let default_para_sheet = res.and_then(|r| r.get("TheNormalParagraphSheet")).and_then(E::as_i64).unwrap_or(0) as usize;
let base_para = res
.and_then(|r| r.get("ParagraphSheetSet"))
.and_then(E::as_array)
.and_then(|a| a.get(default_para_sheet).or(a.first()))
.and_then(|s| s.get("Properties"));
// Style resolution (#3795). A run's own `StyleSheetData` / `Properties` hold only what it
// overrides; the rest comes from its sheets, as in Photoshop:
// - a paragraph run: its `Properties` → the `ParagraphSheetSet` entry its `Name` names (a
// paragraph style; or its `Parent` index) → `TheNormalParagraphSheet`;
// - a character run: its `StyleSheetData` → the `StyleSheetSet` entry its `Parent` or `Name`
// names (a character style) → the default character style of its paragraph (the paragraph run's
// `DefaultStyleSheet`, else its paragraph style's) → `TheNormalStyleSheet`.
// Missing or out-of-range indices fall through to the next level, then to the defaults.
let style_set: &[E] = res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).unwrap_or(&[]);
let para_set: &[E] = res.and_then(|r| r.get("ParagraphSheetSet")).and_then(E::as_array).unwrap_or(&[]);
let index = |key: &str| res.and_then(|r| r.get(key)).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok());
let normal_style = index("TheNormalStyleSheet").and_then(|i| style_set.get(i)).or(style_set.first());
let normal_para = index("TheNormalParagraphSheet").and_then(|i| para_set.get(i)).or(para_set.first());
// A run names its sheet by a `Parent` index or by `Name` (see `psd_styles::reference`).
let named = |set: &'_ [E], sheet: &E| -> Option<usize> {
if let Some(i) = sheet.get("Parent").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < set.len()) {
return Some(i);
}
let name = sheet.get("Name").and_then(E::as_str)?;
set.iter().position(|s| s.get("Name").and_then(E::as_str) == Some(name))
};
// Paragraph runs first: a character run's defaults depend on its paragraph.
let prun = e.path(&["EngineDict", "ParagraphRun"]);
let psheets: Vec<&E> =
prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default();
let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray")));
let plens = utf16_to_byte_lengths(&ed_text, &plens16);
let empty = E::Dict(Vec::new());
// Per paragraph run: its merged base properties and its default character style.
let para_bases: Vec<(E, Option<&E>)> = psheets
.iter()
.map(|sheet| {
let style = named(para_set, sheet).and_then(|i| para_set.get(i));
let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]);
let char_default = [Some(*sheet), style]
.into_iter()
.flatten()
.find_map(|s| s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| style_set.get(i)));
(base, char_default)
})
.collect();
layer.paragraphs = psheets
.iter()
.zip(&para_bases)
.zip(plens)
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) })
.collect();
// Character runs.
let srun = e.path(&["EngineDict", "StyleRun"]);
let sdata: Vec<&E> = srun
.and_then(|r| r.get("RunArray"))
.and_then(E::as_array)
.map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect())
.unwrap_or_default();
let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray"))));
let ssheets: Vec<&E> =
srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default();
let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray")));
let slens = utf16_to_byte_lengths(&ed_text, &slens16);
// The paragraph run each character run starts in (UTF-16 offsets).
let para_of = |at: f64| -> Option<usize> {
let mut end = 0.0;
for (i, n) in plens16.iter().enumerate() {
end += n;
if at < end {
return Some(i);
}
}
plens16.len().checked_sub(1)
};
let mut at16 = 0.0;
let bases: Vec<E> = ssheets
.iter()
.enumerate()
.map(|(i, sheet)| {
let own = named(style_set, sheet).and_then(|i| style_set.get(i));
let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c);
at16 += slens16.get(i).copied().unwrap_or(0.0);
merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))))
})
.collect();
let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect();
let incoming: Vec<(usize, bool, f32)> = sdata
.iter()
.zip(&bases)
.zip(&slens)
.map(|(d, len)| {
let auto = lookup(d, base_style, "AutoKerning").and_then(E::as_bool) != Some(false);
let kern = lookup(d, base_style, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
.map(|((d, base), len)| {
let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false);
let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
(*len, auto, kern)
})
.collect();
layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect();
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect();
layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming);
// Paragraph runs.
let prun = e.path(&["EngineDict", "ParagraphRun"]);
let pdata: Vec<&E> = prun
.and_then(|r| r.get("RunArray"))
.and_then(E::as_array)
.map(|a| a.iter().filter_map(|x| x.path(&["ParagraphSheet", "Properties"])).collect())
.unwrap_or_default();
let plens = utf16_to_byte_lengths(&ed_text, &arr_f(prun.and_then(|r| r.get("RunLengthArray"))));
layer.paragraphs = pdata.iter().zip(plens).map(|(d, len)| ParagraphRun { len, style: para_style(base_para, d, k) }).collect();
// Shape.
let shape = e.path(&["EngineDict", "Rendered", "Shapes", "Children"]).and_then(E::as_array).and_then(|a| a.first());
if let Some(sh) = shape
@@ -432,6 +560,21 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
}
}
/// The dictionaries in `levels` as one: a key comes from the first level that has it.
fn merged(levels: &[Option<&E>]) -> E {
let mut out: Vec<(String, E)> = Vec::new();
for level in levels.iter().flatten() {
if let E::Dict(kv) = level {
for (k, v) in kv {
if !out.iter().any(|(o, _)| o == k) {
out.push((k.clone(), v.clone()));
}
}
}
}
E::Dict(out)
}
fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> {
run.get(key).or_else(|| base.and_then(|b| b.get(key)))
}
+26 -1
View File
@@ -55,6 +55,20 @@ fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option<u
(i != normal && i < set_names.len()).then_some(i)
}
/// Adds to `sheet[key]` (a dictionary) every entry of `resolved` it lacks; existing entries win.
fn fill_missing(sheet: &mut E, key: &str, resolved: E) {
let E::Dict(all) = resolved else { return };
if sheet.get(key).is_none() {
sheet.set(key, E::dict());
}
let Some(E::Dict(have)) = sheet.get_mut(key) else { return };
for (k, v) in all {
if !have.iter().any(|(h, _)| *h == k) {
have.push((k, v));
}
}
}
/// Reads the style sheets of a `TySh` block. None when it has no engine data.
pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
let t = parse_tysh(tysh)?;
@@ -170,6 +184,7 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
rd.set("TheNormalParagraphSheet", E::Int(0));
rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()));
}
let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default();
let char_ix = |id: Option<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
let para_ix = |id: Option<u32>| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
let runs = layer.char_runs();
@@ -193,9 +208,16 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
if let Some(sheet) = r.get_mut("StyleSheet") {
remove_key(sheet, "Parent");
remove_key(sheet, "Name");
if let Some(ix) = char_ix(run_at(start).and_then(|r| r.style.style_sheet)) {
let run = run_at(start);
if let Some(ix) = char_ix(run.and_then(|r| r.style.style_sheet)) {
sheet.set("Parent", E::Int(ix));
}
// The run's reference may be dropped or renumbered: write what it inherited
// too, so it keeps its look whatever sheet it now points to (#3795).
if let Some(run) = run {
let fi = char_fonts.iter().position(|f| *f == postscript_for(&run.style)).unwrap_or(0);
fill_missing(sheet, "StyleSheetData", style_sheet_data(&run.style, fi, k));
}
}
}
}
@@ -208,6 +230,9 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
if let Some(ix) = para_ix(paras.get(i).and_then(|p| p.style.style_sheet)) {
sheet.set("Parent", E::Int(ix));
}
if let Some(p) = paras.get(i) {
fill_missing(sheet, "Properties", paragraph_properties(&p.style, k));
}
}
}
}
+98
View File
@@ -1580,6 +1580,104 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() {
assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::<Vec<_>>());
}
/// #1317: Photoshop keeps the last line of a text box whose baseline is inside the box even
/// when its descenders hang below the bottom edge (three 35 pt lines of 25 pt type in a
/// 91.3 pt box). A line whose baseline falls outside the box is still dropped.
#[test]
fn box_lines_whose_descent_hangs_below_the_box_are_kept() {
let mut e = TextEngine::new();
let mut t = styled("one\ntwo\nthree", CharStyle { size_pt: 25.0, leading_pt: Some(35.0), ..Default::default() });
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 91.27 };
let l = e.layout(&t, 72.0);
assert_eq!(l.lines.len(), 3, "{:?}", l.lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
assert!(l.lines[2].baseline <= 91.27 + 0.5);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 80.0 };
assert_eq!(e.layout(&t, 72.0).lines.len(), 2, "the third baseline (about 88) is below an 80 pt box");
}
/// `Txt2` with one text object for `text` whose rendered frame is frame 0, and that frame:
/// for point text an anchor `point`, and a frame-to-text matrix `matrix` (recent Photoshop,
/// #1317).
fn txt2_with_frame(text: &str, point: Option<[f64; 2]>, matrix: [f64; 6]) -> Vec<u8> {
let mut v = txt2_with_modes(text, &[(text.encode_utf16().count() + 1, 1)]);
// Close the object's model early: `… >> >> ] >>` → `… >> /1 << /2 [ frame ] >> >> ] >>`.
let tail = b"] >> >> >> ] >>";
assert!(v.ends_with(tail));
v.truncate(v.len() - tail.len());
v.extend_from_slice(b"] >> >> /1 << /2 [ << /6 [ << /10 0 >> ] >> ] >> >> ] >>");
let pt = point.map(|[x, y]| format!("/0 [ {x:.5} {y:.5} ] ")).unwrap_or_default();
let m = matrix.map(|x| format!("{x:.5}")).join(" ");
let frames = format!("/0 << /8 << /0 [ << /0 << {pt}/2 << /2 [ {m} ] >> >> >> ] >> >>");
let at = v.windows(9).position(|w| w == b"/0 << >> ").unwrap();
v.splice(at..at + 9, frames.into_bytes().into_iter().chain(*b" "));
v
}
/// #1317: point text whose anchor Photoshop keeps only in its `Txt2` frame (EngineData's
/// `PointBase` at the origin, the transform far away) is placed at that anchor, not at the
/// ink's top left. A rewritten `TySh` (bounds near the origin) is left alone.
#[test]
fn txt2_frame_places_point_text_at_its_anchor() {
let mut t = point("2", 100.0);
t.transform = Affine::translate(-1000.0, -2000.0);
// Photoshop's text space: anchor at (1100, 2050), ink above its baseline.
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0);
let txt2 = crate::psd::parse_txt2(&txt2_with_frame("2", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap();
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
let o = l.transform.apply(photocraft_geom::Point::new(0.0, 0.0));
assert!((o.x - 100.0).abs() < 1e-3 && (o.y - 50.0).abs() < 1e-3, "anchor at {o:?}");
// Already folded in (bounds near the origin): unchanged; another text: unchanged.
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([0.0, -70.0, 60.0, 0.0])), 0);
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
let before = l.transform;
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
assert_eq!(l.transform, before);
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0);
let other = crate::psd::parse_txt2(&txt2_with_frame("3", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap();
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
let before = l.transform;
crate::psd::apply_txt2_frame(&mut l, &tysh, &other);
assert_eq!(l.transform, before);
}
/// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text).
fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec<u8> {
use photocraft_psd::descriptor::{Descriptor, Id, Value};
let mut t = crate::psd::parse_tysh(tysh).unwrap();
let mut d = Descriptor::default();
for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) {
d.items.push((Id::new(k), Value::Double(v)));
}
t.text.items.retain(|(k, _)| !k.is("bounds"));
t.text.items.push((Id::new("bounds"), Value::Descriptor(d)));
crate::psd::write_tysh(&t)
}
/// #1317: a text box whose `BoxBounds` are in its `Txt2` frame's space is moved by the frame's
/// matrix onto the `bounds` Photoshop drew it at (it used to land off the canvas, so its text
/// "disappeared" when an edit started).
#[test]
fn txt2_frame_moves_box_text_onto_its_bounds() {
let mut t = styled("Casemiro 45", CharStyle { size_pt: 25.0, ..Default::default() });
t.shape = TextShape::Box { x: 500.0, y: 600.0, width: 300.0, height: 90.0 };
t.transform = Affine::translate(-700.0, -300.0);
let built = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
let tysh = with_bounds(&built, [960.0, 300.0, 1260.0, 390.0]);
let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Casemiro 45", None, [1.0, 0.0, 0.0, 1.0, 460.0, -300.0])).unwrap();
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
assert!(matches!(l.shape, TextShape::Box { .. }));
crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2);
let TextShape::Box { x, y, .. } = l.shape else { panic!("not a box") };
let o = l.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(y)));
assert!((o.x - 260.0).abs() < 1e-3 && (o.y - 0.0).abs() < 1e-3, "box top left at {o:?}");
// PhotoCraft's own `TySh` (bounds = the box, frame already folded in): unchanged.
let mut l = crate::psd::text_layer_from_tysh(&built, 72.0).unwrap();
let before = l.transform;
crate::psd::apply_txt2_frame(&mut l, &built, &txt2);
assert_eq!(l.transform, before);
}
/// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the
/// fallback stack, so a face without the glyph shows the missing-glyph box instead of a
/// fallback's glyph (#1909).
+7 -2
View File
@@ -30,7 +30,7 @@ photocraft-format = { workspace = true }
photocraft-codecs = { workspace = true }
egui = { workspace = true }
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -49,6 +49,9 @@ sys-locale = "=0.3.2"
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
[dev-dependencies]
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
# `Context::default()` (no install_fonts) can lay out text.
egui = { workspace = true, features = ["default_fonts"] }
# Synthetic raw files for the open-time Camera Raw dialog tests.
photocraft-raw = { workspace = true, features = ["testgen"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
@@ -58,7 +61,9 @@ photocraft-testkit = { workspace = true }
photocraft-psd = { workspace = true }
[build-dependencies]
# Deflates the translation catalogs (build.rs); already linked at run time to inflate them.
# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them.
flate2 = { workspace = true }
# egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses).
epaint_default_fonts = "0.36"
serde_json = { workspace = true }
toml = "1"
+111 -18
View File
@@ -8,10 +8,10 @@
//!
//! A missing or malformed file gives empty tables (or no names) and a warning.
//!
//! Also deflates the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/<code>.tsv.deflate`
//! for `i18n::LANGUAGES`: stored compressed they take ~0.65 MB of the binary instead of ~1.9 MB,
//! which keeps the web build under its 24 MiB size gate (packaging/web/package.sh). A catalog is
//! inflated the first time its language is used.
//! Also packs the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/` for
//! `i18n::LANGUAGES` (see [`pack_catalogs`]): stored that way they take ~0.5 MB of the binary
//! instead of ~2.2 MB, which keeps the web build under its 24 MiB size gate
//! (packaging/web/package.sh). A catalog is unpacked the first time its language is used.
use std::fmt::Write as _;
@@ -27,30 +27,123 @@ fn main() {
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display());
}
deflate_catalogs();
pack_catalogs();
deflate_egui_fonts();
}
/// Deflate every `src/i18n/*.tsv` into `OUT_DIR/i18n/`. A catalog that can't be written is a
/// build error (`include_bytes!` would fail on it anyway, with a less helpful message).
fn deflate_catalogs() {
/// Deflate egui's default fonts (the `epaint_default_fonts` crate) into `OUT_DIR/egui_fonts/` for
/// `egui_fonts.rs`. The app builds egui without `default_fonts`, which would embed the same 1.4 MB
/// uncompressed; deflated they take 0.78 MB (the web build's 24 MiB size gate).
fn deflate_egui_fonts() {
use std::io::Write as _;
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n");
println!("cargo::rerun-if-changed={}", src.display());
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts");
let fonts = [
("Hack-Regular", epaint_default_fonts::HACK_REGULAR),
("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR),
("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT),
("emoji-icon-font", epaint_default_fonts::EMOJI_ICON),
];
let result = (|| -> std::io::Result<()> {
std::fs::create_dir_all(&out)?;
for entry in std::fs::read_dir(&src)? {
let path = entry?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue };
println!("cargo::rerun-if-changed={}", path.display());
for (name, bytes) in fonts {
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
z.write_all(&std::fs::read(&path)?)?;
std::fs::write(out.join(format!("{name}.deflate")), z.finish()?)?;
z.write_all(bytes)?;
std::fs::write(out.join(format!("{name}.ttf.deflate")), z.finish()?)?;
}
Ok(())
})();
if let Err(e) = result {
println!("cargo::error=deflating the translation catalogs in {}: {e}", src.display());
println!("cargo::error=deflating egui's default fonts into {}: {e}", out.display());
}
}
/// Whether a catalog line is an entry (`context<TAB>source<TAB>translation`, source and
/// translation non-empty, as `i18n::catalog::parse_entries` reads it), and its key and translation.
fn catalog_entry(line: &str) -> Option<(&str, &str)> {
if line.starts_with('#') {
return None;
}
let mut cols = line.splitn(3, '\t');
let (ctx, src, tr) = (cols.next()?, cols.next()?, cols.next()?);
let key = line.get(..ctx.len() + 1 + src.len())?;
(!src.is_empty() && !tr.is_empty() && !tr.contains('\t')).then_some((key, tr))
}
/// Pack every `src/i18n/*.tsv` into `OUT_DIR/i18n/`, losslessly (`i18n::unpack` restores each file
/// byte for byte). Every catalog repeats the same English sources, so they are stored once:
///
/// - `keys.deflate`: every entry's `context<TAB>source`, one per line, in order of first
/// appearance (the largest catalog first).
/// - `<code>.tsv.deflate`: the catalog, each entry line replaced by `\x01<translation>` when its key
/// follows the previous entry's in `keys`, else `\x02<offset><TAB><translation>` (the offset from
/// there). Other lines are kept; one that starts with \x01, \x02 or \x03 gets a \x03 prefix.
///
/// Both are deflated. A catalog that can't be written is a build error (`include_bytes!` would
/// fail on it anyway, with a less helpful message).
fn pack_catalogs() {
use std::io::Write as _;
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n");
println!("cargo::rerun-if-changed={}", src.display());
let deflate = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
z.write_all(bytes)?;
z.finish()
};
let result = (|| -> std::io::Result<()> {
std::fs::create_dir_all(&out)?;
let mut catalogs = Vec::new();
for entry in std::fs::read_dir(&src)? {
let path = entry?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue };
println!("cargo::rerun-if-changed={}", path.display());
catalogs.push((name.to_owned(), std::fs::read_to_string(&path)?));
}
// Largest first, then by name: the key order (and so the output) is reproducible.
catalogs.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
let mut keys: Vec<&str> = Vec::new();
let mut index = std::collections::HashMap::new();
for line in catalogs.iter().flat_map(|(_, text)| text.split('\n')) {
if let Some((key, _)) = catalog_entry(line) {
index.entry(key).or_insert_with(|| {
keys.push(key);
keys.len() - 1
});
}
}
std::fs::write(out.join("keys.deflate"), deflate(keys.join("\n").as_bytes())?)?;
for (name, text) in &catalogs {
let mut packed = String::with_capacity(text.len());
let mut next = 0usize;
for (n, line) in text.split('\n').enumerate() {
if n > 0 {
packed.push('\n');
}
match catalog_entry(line) {
Some((key, tr)) => {
let i = index.get(key).copied().unwrap_or_default();
if i == next {
packed.push('\x01');
} else {
let _ = write!(packed, "\x02{}\t", i as i64 - next as i64);
}
packed.push_str(tr);
next = i + 1;
}
None => {
if line.starts_with(['\x01', '\x02', '\x03']) {
packed.push('\x03');
}
packed.push_str(line);
}
}
}
std::fs::write(out.join(format!("{name}.deflate")), deflate(packed.as_bytes())?)?;
}
Ok(())
})();
if let Err(e) = result {
println!("cargo::error=packing the translation catalogs in {}: {e}", src.display());
}
}
+39 -12
View File
@@ -134,19 +134,33 @@ fn dialog_mem(kind: &str) -> egui::Id {
egui::Id::new(("adjust-dialog", kind))
}
/// Whether holding Alt turns the dialog's Cancel into Reset. Measured in Photoshop 25.4 for Color
/// Balance; other kinds keep Cancel until checked against Photoshop.
/// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind).
pub fn resets(fields: &Map<String, Value>) -> bool {
fields.get("__adjust").and_then(Value::as_str) == Some("colorBalance")
owns(fields)
}
/// Alt+Cancel (Reset): back to the defaults Photoshop's Reset restores, dialog stays open.
pub fn reset(ctx: &egui::Context, fields: &mut Map<String, Value>) {
if !resets(fields) {
/// Alt+Cancel (Reset): engine defaults for the kind; Color Balance keeps Preserve Luminosity and
/// shows Midtones (Photoshop 25.4). Dialog stays open.
pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
if !owns(fields) {
return;
}
let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string();
let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode);
// Color Balance: Photoshop keeps the user's Preserve Luminosity across Reset.
let preserve = if kind == "colorBalance" { fields.get("preserveLuminosity").cloned() } else { None };
let Ok(defaults) = photocraft_engine::adjust_params::default_for(&kind, mode) else { return };
let values = photocraft_engine::adjust_params::to_params(&defaults);
// Replace parameter keys; keep private `__` chrome (preview, command, …).
fields.retain(|k, _| k.starts_with("__"));
if let Value::Object(v) = values {
fields.extend(v);
}
if let Some(p) = preserve {
fields.insert("preserveLuminosity".into(), p);
}
let mut values = crate::filter_dialog::params_of(fields);
adjust_editors::reset_color_balance(ctx, dialog_mem("colorBalance"), &mut values);
adjust_editors::reset_adjustment_editor(ctx, &kind, dialog_mem(&kind), &mut values);
if let Value::Object(v) = values {
fields.extend(v);
}
@@ -390,16 +404,29 @@ mod tests {
assert!(h.state().ui.dialogs.is_empty(), "Cancel closes");
}
/// Alt+Cancel is Reset only where it was checked against Photoshop.
/// Every adjustment kind opts into Alt+Reset; Levels restores engine defaults and stays open.
#[test]
fn other_adjustment_dialogs_keep_cancel_under_alt() {
fn levels_alt_reset_restores_defaults() {
use egui_kittest::kittest::Queryable;
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
open(h.state_mut(), "image.adjustments.levels").unwrap();
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
h.run_steps(3);
let id = open(h.state_mut(), "image.adjustments.levels").unwrap();
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30));
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("gamma".into(), json!(1.3));
h.run_steps(2);
assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none());
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
h.run_steps(2);
let reset = h.get_by_label("Reset").rect();
assert!(h.query_by_label("Cancel").is_none());
let click =
|pressed| egui::Event::PointerButton { pos: reset.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT };
input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(reset.center()), click(true), click(false)]);
assert!(h.state().ui.dialogs.iter().any(|d| d.id == id), "Reset keeps the dialog open");
let defaults =
photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap());
assert_eq!(field(&h, id, "inBlack"), defaults["inBlack"]);
assert_eq!(field(&h, id, "gamma"), defaults["gamma"]);
}
fn sample_params(kind: &str) -> Value {
+8
View File
@@ -1019,6 +1019,14 @@ pub fn reset_color_balance(ctx: &egui::Context, mem: egui::Id, v: &mut Value) {
ctx.data_mut(|d| d.insert_temp(color_balance_tone_id(mem), 1usize));
}
/// Kind-specific editor memory after Alt+Reset (or Properties Reset to defaults). Color Balance
/// shows Midtones; other kinds have no dialog-local temp to clear yet.
pub fn reset_adjustment_editor(ctx: &egui::Context, kind: &str, mem: egui::Id, v: &mut Value) {
if kind == "colorBalance" {
reset_color_balance(ctx, mem, v);
}
}
fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let tone_id = color_balance_tone_id(cx.mem);
let mut tone_ix: usize = ui.data(|d| d.get_temp(tone_id)).unwrap_or(1);
+14
View File
@@ -123,6 +123,20 @@ const ENDS_TYPE_EDIT: [&str; 5] =
/// The pasteboard menu's Select Custom Color… row.
pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor";
/// The pasteboard colours in the menu's order, which Space+F steps through.
const CANVAS_COLORS: [CanvasColor; 6] =
[CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom];
/// Photoshop's Space+F (⇧Space+F backwards): the next pasteboard colour, round and round. Saved
/// like a choice in the pasteboard menu (`interface.canvasColor`).
pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), String> {
let n = CANVAS_COLORS.len();
let at = CANVAS_COLORS.iter().position(|c| *c == app.session.prefs().interface.canvas_color).unwrap_or(0);
let next = if backward { (at + n - 1) % n } else { (at + 1) % n };
let name = CANVAS_COLORS.get(next).map_or("default", |c| c.name());
app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ())
}
/// The Zoom and Hand tools' context menu: the zoom steps and fixed zooms of the View menu, plus
/// the Hand tool's Fill Screen.
pub const VIEW_MENU: &[Row] = &[
+21
View File
@@ -298,6 +298,15 @@ pub fn owns(f: &Map<String, Value>) -> bool {
f.contains_key("__colorPicker")
}
/// Alt+Cancel (Reset): restore the colour from when the picker opened (same as clicking Current).
pub fn reset(f: &mut Map<String, Value>) {
if !owns(f) {
return;
}
let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]);
set_rgb(f, orig, Keep::Nothing);
}
/// The Color Picker, when it is the top dialog (another dialog opened over it takes the input).
pub fn top(app: &PhotocraftApp) -> Option<u64> {
app.ui.dialogs.last().filter(|d| owns(&d.fields)).map(|d| d.id)
@@ -833,6 +842,18 @@ mod tests {
assert_eq!(hex([h.state().session.tools.foreground[0], h.state().session.tools.foreground[1], h.state().session.tools.foreground[2]]), "#ff8800");
}
#[test]
fn alt_reset_restores_orig_color() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
let id = open(&mut app, "foreground");
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
f.insert("color".into(), json!("#ff0000"));
reset(f);
assert_eq!(f["color"], json!(hex([0.2, 0.4, 0.6])));
assert_eq!(f["__orig"], json!(hex([0.2, 0.4, 0.6])));
}
#[test]
fn lab_and_cmyk_fields_take_multi_digit_values() {
let base = parse_hex("#336699").unwrap();
+93
View File
@@ -0,0 +1,93 @@
//! Alt+Cancel → Reset for command dialogs that opt in.
//!
//! Holding Alt in the dialog chrome relabels Cancel as Reset and, on click, restores the dialog's
//! fields without closing it (Photoshop). Each marker implements its own `reset`; this module only
//! dispatches. Esc stays Cancel (`Modifiers::NONE` in the frame). Opt out with `"__altReset": false`.
use serde_json::{Map, Value};
use crate::PhotocraftApp;
/// Whether holding Alt turns Cancel into Reset for this dialog's fields.
pub fn supported(fields: &Map<String, Value>) -> bool {
if fields.get("__altReset") == Some(&Value::Bool(false)) {
return false;
}
match_arm(fields).is_some()
}
/// Apply Reset in place. No-op when the dialog has not opted in.
pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, fields: &mut Map<String, Value>) {
if fields.get("__altReset") == Some(&Value::Bool(false)) {
return;
}
// Dispatch order matches `dialogs::show` so `__adjust` wins over `__filter`.
if crate::guide_layout_ui::owns(fields) {
crate::guide_layout_ui::reset(app, fields);
} else if crate::fill_ui::owns(fields) {
crate::fill_ui::reset(app, fields);
} else if crate::variables_ui::owns(fields) {
crate::variables_ui::reset(fields);
} else if crate::color_picker_ui::owns(fields) {
crate::color_picker_ui::reset(fields);
} else if fields.contains_key("__sizing") {
crate::sizing::reset(fields);
} else if crate::adjust_dialog::owns(fields) {
crate::adjust_dialog::reset(app, ctx, fields);
} else if fields.contains_key("__filter") {
crate::filter_dialog::reset_fields(app, fields);
} else if fields.contains_key("__form") {
crate::view_cmds::reset_form(fields);
}
}
/// First matching opt-in arm, if any (same order as [`apply`]).
fn match_arm(fields: &Map<String, Value>) -> Option<&'static str> {
if crate::guide_layout_ui::owns(fields) {
Some("guideLayout")
} else if crate::fill_ui::owns(fields) {
Some("fill")
} else if crate::variables_ui::owns(fields) && fields.contains_key("__variablesOrig") {
Some("variables")
} else if crate::color_picker_ui::owns(fields) {
Some("colorPicker")
} else if fields.contains_key("__sizing") {
Some("sizing")
} else if crate::adjust_dialog::owns(fields) {
Some("adjust")
} else if fields.contains_key("__filter") {
Some("filter")
} else if fields.contains_key("__form") && fields.contains_key("__formOrig") {
Some("form")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn alt_reset_false_opts_out() {
let mut f = Map::new();
f.insert("__adjust".into(), json!("levels"));
f.insert("__altReset".into(), json!(false));
assert!(!supported(&f));
}
#[test]
fn adjust_marker_opts_in() {
let mut f = Map::new();
f.insert("__adjust".into(), json!("levels"));
assert!(supported(&f));
}
#[test]
fn bare_confirm_does_not_opt_in() {
let mut f = Map::new();
f.insert("__command".into(), json!("edit.purge"));
assert!(!supported(&f));
}
}
+3 -2
View File
@@ -310,7 +310,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let can_ok = crate::crop_presets::can_confirm(app, &fields);
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
let reset = ui.input(|i| i.modifiers.alt) && crate::adjust_dialog::resets(&fields);
// Dialogs opt in via `dialog_alt_reset::supported` (per marker).
let reset = ui.input(|i| i.modifiers.alt) && crate::dialog_alt_reset::supported(&fields);
let cancel_label = if reset {
tl!("Reset")
} else if d.kind == DialogKind::NewDocument {
@@ -337,7 +338,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
dialog_buttons(ui, &[ok, cancel])
};
match clicked {
Some(ButtonRole::Cancel) if reset => crate::adjust_dialog::reset(ui.ctx(), &mut fields),
Some(ButtonRole::Cancel) if reset => crate::dialog_alt_reset::apply(app, ui.ctx(), &mut fields),
Some(ButtonRole::Cancel) => outcome = Some(false),
Some(ButtonRole::Apply) => apply_requested = true,
Some(_) => outcome = Some(true),
+91
View File
@@ -0,0 +1,91 @@
//! egui's default fonts (Ubuntu Light, Hack, Noto Emoji and emoji-icon-font: the fallback stack
//! behind Inter and JetBrains Mono, for symbols and emoji), stored deflated.
//!
//! egui's `default_fonts` feature embeds them uncompressed (1.4 MB), which the web build's 24 MiB
//! size gate (packaging/web/package.sh) can't afford. The app builds egui without it; build.rs
//! deflates the same files from the `epaint_default_fonts` crate (0.78 MB) and they are inflated
//! once, the first time the fonts are installed. Test builds keep the feature (dev-dependencies),
//! so egui's own definitions are the oracle for [`definitions`].
use std::sync::{Arc, LazyLock};
use egui::{FontData, FontDefinitions, FontFamily, FontTweak};
/// A font deflated by build.rs.
macro_rules! deflated {
($name:literal) => {
include_bytes!(concat!(env!("OUT_DIR"), "/egui_fonts/", $name, ".ttf.deflate"))
};
}
static HACK: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Hack-Regular")));
static NOTO_EMOJI: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular")));
static UBUNTU_LIGHT: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light")));
static EMOJI_ICON: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("emoji-icon-font")));
/// Inflated fonts are bounded (the largest is 0.42 MB), so corrupt data can't exhaust memory.
const MAX_FONT_BYTES: u64 = 16 << 20;
/// Inflate a font. Data that doesn't inflate gives empty bytes (the font is left out), never a panic.
fn inflate(deflated: &[u8]) -> Vec<u8> {
use std::io::Read as _;
let mut out = Vec::new();
match flate2::read::DeflateDecoder::new(deflated).take(MAX_FONT_BYTES + 1).read_to_end(&mut out) {
Ok(_) if out.len() as u64 <= MAX_FONT_BYTES => out,
_ => Vec::new(),
}
}
/// `FontDefinitions::default()` with egui's default fonts, whether or not egui was built with
/// `default_fonts`: the same faces, tweaks and family order.
pub fn definitions() -> FontDefinitions {
let fonts = FontDefinitions::default();
if fonts.font_data.is_empty() { from_deflated() } else { fonts }
}
/// egui's default definitions, built from the deflated copies.
fn from_deflated() -> FontDefinitions {
let mut fonts = FontDefinitions::empty();
let faces: [(&str, &'static [u8], f32); 4] = [
("Hack", HACK.as_slice(), 1.0),
// egui draws its emoji fonts a little smaller.
("NotoEmoji-Regular", NOTO_EMOJI.as_slice(), 0.81),
("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0),
("emoji-icon-font", EMOJI_ICON.as_slice(), 0.90),
];
for (name, bytes, scale) in faces {
if bytes.is_empty() {
log::error!("egui default font {name} is unavailable");
continue;
}
let data = FontData::from_static(bytes).tweak(FontTweak { scale, ..Default::default() });
fonts.font_data.insert(name.to_owned(), Arc::new(data));
}
let stack = |names: &[&str]| -> Vec<String> { names.iter().filter(|n| fonts.font_data.contains_key(**n)).map(|n| (*n).to_owned()).collect() };
let mono = stack(&["Hack", "Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
let proportional = stack(&["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
fonts.families.insert(FontFamily::Monospace, mono);
fonts.families.insert(FontFamily::Proportional, proportional);
fonts
}
#[cfg(test)]
mod tests {
use super::*;
/// The definitions built from the deflated copies are egui's own: the same bytes, tweaks and
/// family order (test builds enable `default_fonts`, so `FontDefinitions::default()` is egui's).
#[test]
fn deflated_fonts_match_egui_defaults() {
let egui = FontDefinitions::default();
assert!(!egui.font_data.is_empty(), "test builds enable egui's default_fonts (dev-dependencies)");
assert_eq!(from_deflated(), egui);
assert_eq!(definitions(), egui);
}
#[test]
fn corrupt_font_data_is_left_out_not_a_panic() {
assert!(inflate(b"not deflate data").is_empty());
assert!(inflate(&[]).is_empty());
}
}
+3 -1
View File
@@ -719,7 +719,9 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() {
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(written.borrow().len(), 2);
assert_eq!(written.borrow()[0], "/out/boards.jpg");
assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow());
// The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows).
let second = written.borrow()[1].replace('\\', "/");
assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow());
let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::<Vec<_>>();
got.sort_unstable();
assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %");
+21 -2
View File
@@ -53,8 +53,8 @@ fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool {
}
}
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
/// Photoshop's Fill defaults (no last-used prefs). Used by open and Alt+Reset.
pub fn default_fields(app: &PhotocraftApp) -> Map<String, Value> {
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
@@ -68,6 +68,12 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
f.insert("mode".into(), json!("normal"));
f.insert("opacity".into(), json!(100.0));
f.insert("preserveTransparency".into(), json!(false));
f
}
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
let mut f = default_fields(app);
if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) {
for k in KEYS {
if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) {
@@ -78,6 +84,19 @@ pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
f
}
/// Alt+Cancel (Reset): Photoshop Fill defaults, not last-used prefs. Dialog stays open.
pub fn reset(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
if !owns(fields) {
return;
}
let defaults = default_fields(app);
for k in KEYS {
if let Some(v) = defaults.get(k) {
fields.insert(k.into(), v.clone());
}
}
}
/// Open the Fill dialog; returns its id.
pub fn open(app: &mut PhotocraftApp) -> u64 {
let f = fields(app);
+16
View File
@@ -74,6 +74,22 @@ fn mode_opacity_and_preserve_transparency() {
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]));
}
#[test]
fn alt_reset_restores_photoshop_defaults_not_last_used() {
let mut app = app();
// Remember a non-default choice, then open (which restores it) and Reset.
fill(&mut app, json!({"contents": "white", "opacity": 40})).unwrap();
let id = open(&mut app);
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64(), Some(40.0));
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
reset(&app, &mut fields);
app.ui.dialog_mut(id).unwrap().fields = fields;
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!(f["opacity"].as_f64(), Some(100.0));
assert_eq!(f["contents"], json!("foreground"));
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
}
#[test]
fn choices_persist_across_restarts() {
let mut app = app();
+86 -37
View File
@@ -327,6 +327,70 @@ pub(crate) fn remember(app: &mut PhotocraftApp, command: &str, fields: &Map<Stri
}
}
/// Schema-default values for one param of `command` (open and Alt+Reset share this).
fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option<Value> {
Some(match &p.kind {
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
Kind::Range { default, .. } => json!(default),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Text => json!(""),
Kind::Document => json!(-1),
// Identity kernel: 1 in the centre.
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
Kind::Color(None) => return None,
// Colour inputs come from the current swatches, so the proxy preview matches the result.
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
Kind::Json if p.key == "background" => json!(app.session.tools.background),
Kind::Json => return None,
})
}
/// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs.
fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map<String, Value>) {
if command == "image.rotation.arbitrary" {
straighten_defaults(app, fields);
}
if command == "type.warpText" {
warp_defaults(app, fields);
}
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
if command == "edit.definePattern" {
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
}
if command == "layer.layerStyle.globalLight" {
global_light_defaults(app, fields);
}
if command == "view.proofSetup" {
proof_setup_defaults(app, fields);
}
}
/// Alt+Cancel (Reset): schema defaults + command hooks, without last-used remembered prefs.
pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
if !fields.contains_key("__filter") || fields.contains_key("__adjust") {
return;
}
let Some(command) = fields.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
let spec =
fields.get("__spec").and_then(Value::as_str).map(str::to_owned).or_else(|| photocraft_engine::commands::find(&command).map(|c| c.params.to_owned()));
let Some(spec) = spec else { return };
// Drop user params; keep private `__` chrome (and fixed keys outside the schema for `__spec`).
let schema_keys: std::collections::HashSet<String> = parse_spec(&spec).into_iter().map(|p| p.key).collect();
fields.retain(|k, _| k.starts_with("__") || !schema_keys.contains(k));
for p in parse_spec(&spec) {
if let Some(v) = default_value(app, &command, &p) {
fields.insert(p.key, v);
}
}
apply_command_defaults(app, &command, fields);
}
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let spec = photocraft_engine::commands::find(command)?;
let mut fields = Map::new();
@@ -338,45 +402,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
fields.insert("__preview".into(), json!(true));
}
for p in parse_spec(spec.params) {
let v = match &p.kind {
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
Kind::Range { default, .. } => json!(default),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Text => json!(""),
Kind::Document => json!(-1),
// Identity kernel: 1 in the centre.
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
Kind::Color(None) => continue,
// Colour inputs come from the current swatches, so the proxy preview matches the result.
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
Kind::Json if p.key == "background" => json!(app.session.tools.background),
Kind::Json => continue,
};
fields.insert(p.key, v);
if let Some(v) = default_value(app, command, &p) {
fields.insert(p.key, v);
}
}
restore_remembered(app, command, spec.params, &mut fields);
if command == "image.rotation.arbitrary" {
straighten_defaults(app, &mut fields);
}
if command == "type.warpText" {
warp_defaults(app, &mut fields);
}
// Photoshop's Pattern Name dialog starts from the name the pattern would get anyway.
if command == "edit.definePattern" {
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
}
if command == "layer.layerStyle.globalLight" {
global_light_defaults(app, &mut fields);
}
if command == "view.proofSetup" {
proof_setup_defaults(app, &mut fields);
}
apply_command_defaults(app, command, &mut fields);
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
// The document picker lists every open document (params refer to them by index).
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
@@ -818,6 +849,24 @@ mod tests {
assert_eq!(fields["radius"], json!(2.0));
}
#[test]
fn alt_reset_restores_schema_defaults_not_remembered() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.prefs.edit(|prefs| {
prefs.dialogs.insert("filter.blur.gaussianBlur".into(), json!({"radius": 11.0}));
});
let id = open(&mut app, "filter.blur.gaussianBlur").unwrap();
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["radius"], json!(11.0));
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
reset_fields(&app, &mut fields);
app.ui.dialog_mut(id).unwrap().fields = fields;
let f = &app.ui.dialog_mut(id).unwrap().fields;
// Registry default for gaussian blur radius is 1.
assert_eq!(f["radius"], json!(1.0));
assert!(f.contains_key("__filter"));
assert!(app.ui.dialogs.iter().any(|d| d.id == id));
}
#[test]
fn remembering_dialogs_never_persists_paths_or_document_indices() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+50 -2
View File
@@ -30,7 +30,7 @@ const CENTER_Y_FIELD: &str = "__pvCy";
/// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A
/// no-op for dialogs without a preview and for Select and Mask, which has its own view.
pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if !f.contains_key("__preview") || crate::select_mask_view::owns(f) {
if !shows(f) {
return;
}
let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return };
@@ -145,7 +145,14 @@ const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0];
/// The vertical space the preview window takes below the parameters: the image, the zoom row and
/// their spacing. `0` when the dialog has no preview (so the caller reserves nothing).
pub(crate) fn reserved_height(f: &Map<String, Value>) -> f32 {
if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 }
if shows(f) { PREVIEW_H + 32.0 } else { 0.0 }
}
/// Whether the dialog with fields `f` gets the preview window: Filter menu dialogs with a Preview
/// checkbox, as in Photoshop. Image › Rotation › Arbitrary and the adjustment dialogs keep only
/// the on-canvas preview, and Select and Mask has its own view.
fn shows(f: &Map<String, Value>) -> bool {
f.contains_key("__preview") && !crate::select_mask_view::owns(f) && f.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("filter."))
}
/// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends.
@@ -194,12 +201,29 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 {
/// A composite as monitor pixels, matching the canvas (`canvas::display_image`).
fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle {
// Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution
// previews off the proxy is the full document. The view maps through UV fractions, so a reduced
// texture still lines up.
let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side));
let reduced;
let doc = if k > 1 {
reduced = crate::proxy::proxy_document(doc, k);
&reduced
} else {
doc
};
let buf = photocraft_compose::flatten(doc);
let (display, _) = crate::canvas::canvas_display(app, doc, None);
let img = crate::canvas::display_image(display.as_deref(), &buf);
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
}
/// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits).
fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 {
let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1);
w.max(h).div_ceil(max_side).max(1)
}
/// What a cached texture was built from, so it is rebuilt only when its source changes.
#[derive(Clone, PartialEq)]
enum TexKey {
@@ -238,6 +262,30 @@ mod tests {
assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM);
}
#[test]
fn only_filter_dialogs_get_the_preview_window() {
let fields = |cmd: &str| json!({"__preview": true, "__command": cmd}).as_object().cloned().unwrap();
assert!(shows(&fields("filter.blur.gaussianBlur")));
assert!(reserved_height(&fields("filter.blur.gaussianBlur")) > 0.0);
// Photoshop's Rotate Canvas and adjustment dialogs have no preview window.
assert!(!shows(&fields("image.rotation.arbitrary")));
assert_eq!(reserved_height(&fields("image.rotation.arbitrary")), 0.0);
assert!(!shows(&fields("adjust.levels")));
// No Preview checkbox or no command: no window.
assert!(!shows(json!({"__command": "filter.blur.gaussianBlur"}).as_object().unwrap()));
assert!(!shows(json!({"__preview": true}).as_object().unwrap()));
}
#[test]
fn textures_are_reduced_to_fit_the_gpu() {
assert_eq!(texture_factor(3000, 2000, 2048), 2);
assert_eq!(texture_factor(2048, 100, 2048), 1);
assert_eq!(texture_factor(9000, 9000, 2048), 5);
// A zero or absurd limit never divides by zero.
assert_eq!(texture_factor(3000, 2000, 0), 3000);
assert_eq!(texture_factor(0, 0, 2048), 1);
}
#[test]
fn the_centre_is_clamped_to_the_document() {
assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0));
+27
View File
@@ -31,6 +31,33 @@ pub fn owns(f: &Map<String, Value>) -> bool {
f.contains_key(MARK)
}
/// Alt+Cancel (Reset): factory New Guide Layout values, not the last-used remembered layout.
pub fn reset(app: &PhotocraftApp, f: &mut Map<String, Value>) {
if !owns(f) {
return;
}
let Value::Object(defaults) = crate::view_cmds::default_guide_layout() else { return };
// Drop layout params and interactive toggles; keep dialog chrome (`__command`, …).
f.retain(|k, _| {
k.starts_with("__")
&& !matches!(
k.as_str(),
"__preset" | "__columnsEnabled" | "__rowsEnabled" | "__marginEnabled" | "__previewGuides" | "__previewParams" | "__previewSource"
)
});
f.extend(defaults);
f.insert("color".into(), json!(app.session.prefs().guides_grid_and_slices.guide_color));
f.insert("target".into(), json!("document"));
f.insert("__preset".into(), json!("custom"));
f.insert("width".into(), Value::Null);
f.insert("height".into(), Value::Null);
let margin = f.get("margin").cloned().unwrap_or(json!(0));
if !margin.is_array() {
f.insert("margin".into(), json!([margin.clone(), margin.clone(), margin.clone(), margin]));
}
f.insert("__preview".into(), json!(true));
}
fn on(f: &Map<String, Value>, key: &str, fallback: bool) -> bool {
f.get(key).and_then(Value::as_bool).unwrap_or(fallback)
}
@@ -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.
+1
View File
@@ -398,6 +398,7 @@
Select Linked Layers Vybrat propojené vrstvy
Merge Layers Sloučit vrstvy
Merge Down Sloučit dolů
Merge Group Sloučit skupinu
Merge Visible Sloučit viditelné
Stamp Visible Otisknout viditelné
Select Layer Above Vybrat vrstvu nad
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -394,6 +394,7 @@
Select Linked Layers Verknüpfte Ebenen auswählen
Merge Layers Ebenen zusammenfügen
Merge Down Nach unten zusammenfügen
Merge Group Gruppe zusammenfügen
Merge Visible Sichtbare zusammenfügen
Stamp Visible Sichtbare Ebenen stempeln
Select Layer Above Ebene darüber auswählen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1838,6 +1838,7 @@
New Guide Layout… Νέα διάταξη οδηγών…
New Guides From Shape Νέοι οδηγοί από σχήμα
Merge Down Συγχώνευση με το υποκείμενο επίπεδο
Merge Group Συγχώνευση ομάδας
Stamp Visible Συνένωση ορατών σε νέο επίπεδο
Select Layer Above Επιλογή επάνω επιπέδου
Select Layer Below Επιλογή κάτω επιπέδου
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+1
View File
@@ -391,6 +391,7 @@
Select Linked Layers Seleccionar capas vinculadas
Merge Layers Combinar capas
Merge Down Combinar hacia abajo
Merge Group Combinar grupo
Merge Visible Combinar visibles
Stamp Visible Sellar visibles
Select Layer Above Seleccionar la capa de arriba
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -387,6 +387,7 @@
Select Linked Layers Sélectionner les calques liés
Merge Layers Fusionner les calques
Merge Down Fusionner avec le calque inférieur
Merge Group Fusionner le groupe
Merge Visible Fusionner les calques visibles
Stamp Visible Tamponner les calques visibles
Stamp Down Tamponner sur le calque inférieur
Can't render this file because it contains an unexpected character in line 6 and column 47.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -380,6 +380,7 @@
Select Linked Layers Pilih Layer Tertaut
Merge Layers Gabungkan Layer
Merge Down Gabung ke Bawah
Merge Group Gabungkan Grup
Merge Visible Gabung yang Terlihat
Stamp Visible Stamp yang Terlihat
Stamp Down Stamp ke Bawah
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -390,6 +390,7 @@
Select Linked Layers Seleziona livelli collegati
Merge Layers Unisci livelli
Merge Down Unisci sotto
Merge Group Unisci gruppo
Merge Visible Unisci visibili
Stamp Visible Unisci visibili in nuovo
Select Layer Above Seleziona livello sopra
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
390
391
392
393
394
395
396
+1
View File
@@ -391,6 +391,7 @@
Select Linked Layers リンクしたレイヤーを選択
Merge Layers レイヤーを結合
Merge Down 下のレイヤーと結合
Merge Group グループを結合
Merge Visible 表示レイヤーを結合
Stamp Visible 表示レイヤーをスタンプ
Select Layer Above 上のレイヤーを選択
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -375,6 +375,7 @@
Select Linked Layers 연결된 레이어 선택
Merge Layers 레이어 병합
Merge Down 아래 레이어와 병합
Merge Group 그룹 병합
Merge Visible 보이는 레이어 병합
Stamp Visible 보이는 레이어의 병합 사본 만들기
Select Layer Above 위 레이어 선택
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
375
376
377
378
379
380
381
+91 -8
View File
@@ -39,7 +39,7 @@ pub struct LangInfo {
/// Catalog file contents, raw-deflated by build.rs (empty for the built-in English); see
/// [`LangInfo::text`].
pub source: &'static [u8],
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese and Chinese: always 0;
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese, Chinese and Hungarian: always 0;
/// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per
/// index.
pub plural: fn(u64) -> usize,
@@ -115,8 +115,8 @@ fn plural_polish(n: u64) -> usize {
}
}
/// A catalog file as deflated by build.rs (the binary would otherwise carry ~1.9 MB of
/// translations, which the web build's size gate can't afford).
/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB
/// of translations, which the web build's size gate can't afford). See [`unpack`].
macro_rules! catalog {
($code:literal) => {
include_bytes!(concat!(env!("OUT_DIR"), "/i18n/", $code, ".tsv.deflate"))
@@ -140,8 +140,54 @@ fn inflate(source: &[u8]) -> Result<String, String> {
Ok(text)
}
/// Every catalog entry's `context<TAB>source`, stored once for all catalogs (build.rs).
static KEYS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/i18n/keys.deflate"));
/// Restore a catalog file from its packed form (build.rs `pack_catalogs`): entry lines refer to
/// [`KEYS`] instead of repeating the English source. Bad data gives an error (and the language
/// falls back to English), never a panic.
fn unpack(source: &[u8]) -> Result<String, String> {
static KEY_TEXT: OnceLock<Result<String, String>> = OnceLock::new();
if source.is_empty() {
return Ok(String::new());
}
let keys: Vec<&str> = KEY_TEXT.get_or_init(|| inflate(KEYS)).as_ref().map_err(|e| format!("catalog keys: {e}"))?.split('\n').collect();
expand(&inflate(source)?, &keys)
}
/// [`unpack`]'s second step: replace each packed entry line with its key and translation.
fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
let mut text = String::with_capacity(packed.len().saturating_mul(2));
let mut next = 0usize;
for (n, line) in packed.split('\n').enumerate() {
if n > 0 {
text.push('\n');
}
let bad = || format!("packed line {}: bad key reference", n + 1);
let (index, tr) = if let Some(tr) = line.strip_prefix('\x01') {
(next, tr)
} else if let Some(rest) = line.strip_prefix('\x02') {
let (offset, tr) = rest.split_once('\t').ok_or_else(bad)?;
let offset: i64 = offset.parse().map_err(|_| bad())?;
let index = i64::try_from(next).ok().and_then(|n| n.checked_add(offset)).and_then(|i| usize::try_from(i).ok()).ok_or_else(bad)?;
(index, tr)
} else {
text.push_str(line.strip_prefix('\x03').unwrap_or(line));
continue;
};
text.push_str(keys.get(index).ok_or_else(bad)?);
text.push('\t');
text.push_str(tr);
next = index.saturating_add(1);
if text.len() as u64 > MAX_CATALOG_BYTES {
return Err(format!("over {MAX_CATALOG_BYTES} bytes"));
}
}
Ok(text)
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 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 +210,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() },
];
@@ -171,7 +221,7 @@ pub static LANGUAGES: [LangInfo; 18] = [
impl LangInfo {
/// The catalog file's text (empty for English, or if the bundled data can't be inflated).
pub fn text(&self) -> String {
inflate(self.source).unwrap_or_else(|e| {
unpack(self.source).unwrap_or_else(|e| {
log::error!("translation catalog {}: {e}; showing English", self.code);
String::new()
})
@@ -418,6 +468,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 +568,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");
@@ -677,13 +748,13 @@ mod tests {
assert_eq!(tr(CS(), "All"), "Vše");
}
/// The deflated catalogs in the binary inflate to exactly the `.tsv` files.
/// The packed catalogs in the binary unpack to exactly the `.tsv` files.
#[test]
fn deflated_catalogs_match_their_files() {
fn packed_catalogs_match_their_files() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n");
for l in LANGUAGES.iter().skip(1) {
let file = std::fs::read_to_string(dir.join(format!("{}.tsv", l.code))).unwrap();
assert_eq!(inflate(l.source).unwrap(), file, "{}", l.code);
assert_eq!(unpack(l.source).unwrap(), file, "{}", l.code);
assert!(l.source.len() < file.len() / 2, "{}: stored deflated", l.code);
}
assert_eq!(LANGUAGES[0].text(), "", "English has no catalog");
@@ -707,6 +778,18 @@ mod tests {
assert_eq!(tr(Lang(info), "Layer"), "Layer", "falls back to English");
}
/// Packed lines unpack to their keys; references to keys that don't exist are an error, not a
/// panic.
#[test]
fn packed_lines_unpack_and_bad_references_are_errors() {
let keys = ["\tOpen", "@id\tfile.open", "\tClose"];
let packed = "# c\n\x01Ouvrir\n\x02-1\tOuvrir\n\x02+1\tFermer\n\x03\x01x\n";
assert_eq!(expand(packed, &keys).unwrap(), "# c\n\tOpen\tOuvrir\n\tOpen\tOuvrir\n\tClose\tFermer\n\x01x\n");
for bad in ["\x02-1\tx", "\x02 3\tx", "\x023", "\x029223372036854775807\tx", "\x02-9223372036854775808\tx", "\x01a\n\x01b\n\x01c\n\x01d"] {
assert!(expand(bad, &keys).is_err(), "{bad:?}");
}
}
/// Every bundled catalog is well-formed and consistent with its sources.
#[test]
fn bundled_catalogs_are_consistent() {
+1
View File
@@ -383,6 +383,7 @@
Select Linked Layers Gekoppelde lagen selecteren
Merge Layers Lagen samenvoegen
Merge Down Omlaag samenvoegen
Merge Group Groep samenvoegen
Merge Visible Zichtbare samenvoegen
Stamp Visible Zichtbare stempelen
Select Layer Above Laag erboven selecteren
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -389,6 +389,7 @@
Select Linked Layers Zaznacz połączone warstwy
Merge Layers Scal warstwy
Merge Down Scal w dół
Merge Group Scal grupę
Merge Visible Scal widoczne
Stamp Visible Odbij widoczne
Select Layer Above Zaznacz warstwę powyżej
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -383,6 +383,7 @@
Select Linked Layers Selecionar camadas vinculadas
Merge Layers Mesclar camadas
Merge Down Mesclar para baixo
Merge Group Mesclar grupo
Merge Visible Mesclar visíveis
Stamp Visible Carimbar visíveis
Stamp Down Carimbar para baixo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1897,6 +1897,7 @@
Lock Layers… Заблокировать слои…
Mean Среднее
Merge Down Выполнить сведение
Merge Group Объединить группу
Merge Layers Объединить слои
Merge Shape Components Объединить компоненты фигуры
Merge Visible Свести видимые
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -96,6 +96,8 @@ mod tests {
(vec!["pt-PT"], "pt-br"),
(vec!["it-IT", "en-US"], "it"),
(vec!["nl-BE", "fr-BE"], "nl"),
(vec!["hu-HU", "en-US"], "hu"),
(vec!["tr-TR", "en-US"], "tr"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1876,6 +1876,7 @@
Lock Layers… Заблокувати шари…
Mean Середнє значення
Merge Down Об'єднати з нижнім
Merge Group Об'єднати групу
Merge Layers Об'єднати шари
Merge Shape Components Об'єднати компоненти фігури
Merge Visible Об'єднати видимі
Can't render this file because it contains an unexpected character in line 1356 and column 18.

Some files were not shown because too many files have changed in this diff Show More