Merge main; BUG-208-4 done, rows re-checked against main

Re-checked every row this PR flips against current main: the cited
tests still exist and none of the rows was flipped meanwhile.
BUG-208-4 is done too: shift_alt_before_the_drag_intersects_the_selection
(marquee_tests.rs) covers the marquee Shift+Alt intersect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-15 00:29:48 +02:00
294 changed files with 21518 additions and 2132 deletions
+4 -2
View File
@@ -13,6 +13,7 @@ next to them.
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek 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/de.tsv` | German 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/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | 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/it.tsv` | Italian 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/ko.tsv` | Korean 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/nl.tsv` | Dutch 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) |
@@ -26,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) 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`, `gradient.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) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -41,6 +42,7 @@ next to them.
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -112,6 +114,6 @@ next to the files:
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
Generated
+29 -29
View File
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"arboard",
"async-io",
@@ -4657,7 +4657,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4667,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4680,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4702,7 +4702,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4724,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4745,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4753,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4768,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,7 +4779,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"craft-print",
@@ -4814,7 +4814,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4835,14 +4835,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"half",
"log",
@@ -4859,14 +4859,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4895,14 +4895,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4913,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4928,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,7 +4939,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4949,7 +4949,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4964,7 +4964,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4987,7 +4987,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"parley",
@@ -5001,7 +5001,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5042,7 +5042,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5053,7 +5053,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -8651,7 +8651,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.2"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -1
View File
@@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
+1 -3
View File
@@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
+5
View File
@@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
+1 -14
View File
@@ -340,20 +340,7 @@ fn services(inbox: Inbox) -> Services {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
+17 -2
View File
@@ -233,9 +233,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]).
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -635,6 +636,20 @@ mod tests {
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
/// GL drew a black window; WARP works. GL stays the last resort.
#[test]
fn windows_auto_prefers_warp_over_gl() {
use wgpu::{Backend as B, DeviceType as T};
let svga_gl = cand(0x15ad, T::Other, B::Gl);
let warp = cand(0x1414, T::Cpu, B::Dx12);
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
// Elsewhere GL keeps its rank.
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
+7 -15
View File
@@ -228,20 +228,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
opts
settings.io_options()
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
@@ -907,6 +894,7 @@ mod tests {
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
autosave(&mut app, &ctx);
let mut failed = false;
@@ -927,12 +915,16 @@ mod tests {
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
if recovered.iter().any(|e| e.info.revision == revision) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
+67 -6
View File
@@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
#[cfg(any(target_os = "linux", test))]
const ABS_X: u32 = 0x00;
#[cfg(any(target_os = "linux", test))]
const ABS_Y: u32 = 0x01;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
let bits = usize::BITS;
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
#[cfg(any(target_os = "linux", test))]
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
/// X app would outlive a mouse stroke here), so nothing starts.
@@ -238,6 +256,49 @@ mod tests {
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
/// the device's own, from `/proc/bus/input/devices`.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"RustDesk UInput Keyboard\"\n\
P: Phys=\n\
S: Sysfs=/devices/virtual/input/input54\n\
U: Uniq=\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: PROP=0\n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
B: MSC=10\n\
B: LED=7\n";
#[test]
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
assert!(
bit_set(
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
BTN_TOOL_PEN
),
"the fixture claims the key"
);
assert!(!has_pen(VIRTUAL_KEYBOARD));
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
// pen's device and the pen's axes never into the keyboard's.
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
assert!(has_pen(&both));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
assert!(has_pen(pen_display));
// Axes without `ABS_X` or `ABS_Y` don't count.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
}
#[test]
+77 -41
View File
@@ -81,17 +81,16 @@ mod platform {
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
@@ -100,34 +99,58 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
let Some((left, top, right, bottom)) =
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
else {
return;
};
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
return None;
}
if b <= wb + side {
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
return None;
}
Some(wb + side - t)
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
@@ -139,7 +162,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_height};
use super::{borderless, clamped_rect};
use winsafe::co;
#[test]
@@ -156,34 +179,47 @@ mod platform {
}
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
// plus the frame the window was parked with.
fn a_maximize_respects_taskbars_on_every_edge() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None);
}
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom
// is still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

After

Width:  |  Height:  |  Size: 466 B

+4
View File
@@ -158,3 +158,7 @@ sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+4 -2
View File
@@ -132,8 +132,10 @@ test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
+1 -1
View File
@@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+1 -1
View File
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+6 -1
View File
@@ -26,11 +26,16 @@ pub struct Limits {
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
/// temporary planes the layer being read decodes through. The entry limits bound what is
/// extracted, not what the layers materialized out of it cost (#4960).
pub max_pixel_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
}
}
+19 -3
View File
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
Reader::new(&s, &mut archive, limits).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
/// output, which stays with the document; `planes` is the working set it decodes through and
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
/// three together.
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
let peak = rgba.saturating_add(planes);
if peak > self.pixels_left {
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
}
self.pixels_left -= rgba;
Ok(())
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+5
View File
@@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
};
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
// and the planes it decodes through before either is allocated (#4960).
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
+81 -2
View File
@@ -251,9 +251,9 @@ mod archive {
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
@@ -459,3 +459,82 @@ mod shapes {
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
}
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
mod pixels {
use crate::Error;
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::synth::{self, F, Method, tag};
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
fn layer(id: u32, w: i32, h: i32) -> F {
F::Def(
id,
vec![tag(b"Rstr")],
vec![
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
],
)
}
/// A document whose one page holds a pixel layer per `sizes` entry.
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
let data = synth::stream(&[(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
)]),
)],
),
)]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn imported_layers(d: &Document) -> usize {
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
}
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
const ONE_LAYER: usize = 40 << 10;
#[test]
fn a_pixel_layer_within_the_budget_imports() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
}
#[test]
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
let bytes = document(&[(64, 64), (64, 64)]);
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
// Two layers fit once the budget covers their pixels together.
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
}
#[test]
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
assert!(
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
"16 MiB of declared pixels materialized under a 1 MiB budget"
);
}
}
+43 -7
View File
@@ -281,7 +281,7 @@ fn warp(
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
@@ -366,10 +366,10 @@ fn warp(
return Ok(None);
}
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
@@ -392,6 +392,15 @@ fn warp(
px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64
}
};
// Samples past the source rect repeat its edge, so a stretched edge row or column keeps its
// colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage
// instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels.
let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c);
let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0);
let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0));
let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 };
let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs());
let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs());
let w = t.width() as usize;
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
@@ -424,7 +433,7 @@ fn warp(
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix + dx, iy + dy, c) * wx * wy;
*s += clamped(ix + dx, iy + dy, c) * wx * wy;
}
}
}
@@ -436,12 +445,21 @@ fn warp(
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
}
}
}
}
}
if interp != Interp::Nearest {
// How much of this pixel the warped source rect covers, per axis.
let (uc, vc) = (u + 0.5, v + 0.5);
let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0);
let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0);
for s in acc.iter_mut().take(n) {
*s *= cu * cv;
}
}
let al = acc[a].clamp(0.0, 1.0);
if al <= 0.0 {
continue;
@@ -539,6 +557,24 @@ mod tests {
Surface::new(PixelFormat::RGBA8)
}
/// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in
/// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased.
#[test]
fn stretched_single_column_stays_opaque() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]);
for interp in [Interp::Bicubic, Interp::Bilinear] {
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap();
let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap();
assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}");
for x in [0, 1, 25, 50, 75, 99] {
for y in 0..4 {
assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3));
}
}
}
}
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
#[test]
+59
View File
@@ -0,0 +1,59 @@
use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
#[test]
fn clipped_warp_matches_the_existing_kernel_at_every_depth() {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]);
source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]);
let clip = Rect::new(10, 10, 52, 52);
for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] {
let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap();
let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap();
assert_eq!(full.read_region(clip), bounded.read_region(clip));
assert!(clip.contains_rect(&bounded.content_bounds()));
assert!(bounded.tiles().count() <= clip.tiles().count());
}
}
}
}
#[test]
fn huge_scaled_warp_allocates_only_the_destination_clip() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]);
let clip = Rect::new(0, 0, 64, 64);
let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
assert_eq!(output.pixel(32, 32), source.pixel(10, 10));
assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none());
}
#[test]
fn inverse_footprint_handles_extreme_finite_affines() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
// A strongly skewed inverse maps a small visible tile beyond i32 limits.
let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let clip = Rect::new(0, 0, 32, 32);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
}
#[test]
fn source_center_does_not_add_extreme_coordinates_in_i32() {
let mut source = Surface::new(PixelFormat::RGBA8);
let origin = 1_500_000_000;
let bounds = Rect::new(origin, origin, origin + 4, origin + 4);
source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]);
let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap();
assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2));
}
+7 -13
View File
@@ -91,17 +91,9 @@ impl Headless {
let all = self.command_list();
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
None => all,
Some(n) => Value::Array(
all.as_array()
.into_iter()
.flatten()
.filter(|c| {
let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""));
hay.to_lowercase().contains(&n)
})
.cloned()
.collect(),
),
Some(n) => {
Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
}
})
}
"session.list" => Ok(self.session_list()),
@@ -365,7 +357,7 @@ mod tests {
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
/// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
@@ -377,8 +369,10 @@ mod tests {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect();
// `keywords` is optional: only commands with other search names carry it (#5060).
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect();
keys.sort_unstable();
assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}");
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
+5 -5
View File
@@ -205,7 +205,7 @@ pub struct SelectParams {
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ListParams {
/// Only commands whose id or label contains this text (case-insensitive).
/// Only commands whose id, label or keywords contain this text (case-insensitive).
#[serde(default)]
pub filter: Option<String>,
/// Only commands that can run right now.
@@ -320,7 +320,8 @@ pub struct UiSetParams {
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
@@ -821,9 +822,8 @@ impl PhotocraftMcp {
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
.into_iter()
.filter(|c| {
let hay =
format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase();
needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
&& (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
})
.collect();
Ok(ok_json(&Value::Array(items)))
+3 -1
View File
@@ -363,7 +363,7 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
@@ -758,6 +758,7 @@ mod tests {
fn automation_rejects_ambient_path_commands_and_preferences() {
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})),
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
@@ -772,6 +773,7 @@ mod tests {
}
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
("brush.presets.importImage", serde_json::json!({"data": "QUJD"})),
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
+3
View File
@@ -167,6 +167,9 @@ async fn command_list_filters() {
let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await);
assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all);
assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string()));
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await);
assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}");
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
+3 -3
View File
@@ -1,6 +1,6 @@
# photocraft-cms
Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable),
Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable),
no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2)
specifications, CIE 15, and Adobe's published black point compensation paper.
@@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
| `lab-d50` | Lab identity (v4 encoding) |
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
### The CMYK profile
Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely
downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry
redistribution conditions or unclear licences, so Photocraft generates its own
redistribution conditions or unclear licences, so PhotoCraft generates its own
(`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY,
17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink
Binary file not shown.
+22 -12
View File
@@ -26,6 +26,8 @@ pub enum Builtin {
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
AcesCg,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
@@ -43,12 +45,13 @@ pub enum Builtin {
}
impl Builtin {
pub const ALL: [Builtin; 11] = [
pub const ALL: [Builtin; 12] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::AcesCg,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
@@ -65,6 +68,7 @@ impl Builtin {
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::AcesCg => "aces-cg",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
@@ -79,14 +83,15 @@ impl Builtin {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
}
@@ -104,6 +109,7 @@ impl Builtin {
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
@@ -113,7 +119,7 @@ impl Builtin {
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
static CELLS: [OnceLock<Profile>; 12] = [const { OnceLock::new() }; 12];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
@@ -140,7 +146,7 @@ pub fn srgb() -> &'static Profile {
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
@@ -256,12 +262,13 @@ fn lab() -> Profile {
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
fn build(b: Builtin) -> Profile {
let (srgb_p, p3, adobe, romm, bt2020) = (
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
);
match b {
Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()),
@@ -269,6 +276,9 @@ fn build(b: Builtin) -> Profile {
Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)),
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
+148 -36
View File
@@ -429,10 +429,18 @@ impl<'a> Reader<'a> {
Ok(self.u32(off)? as i32 as f64 / 65536.0)
}
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
let r = Reader { b: self.slice(off, 12)? };
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
}
}
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
}
fn sig(s: &[u8; 4]) -> u32 {
u32::from_be_bytes(*s)
}
@@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
match r.slice(0, 4)? {
b"curv" => {
let n = r.u32(8)? as usize;
// Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976).
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let len = 12 + n * 2;
match n {
0 => Ok((Curve::Identity, len)),
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
_ => {
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let s = r.slice(12, n * 2)?;
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
Ok((Curve::Table(t), len))
@@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
}
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
let matrix = |off: usize| -> Result<Stage, CmsError> {
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = r.s15f16(off + k * 4)?;
}
let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
};
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = r.u8(off + k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = r.u8(off + 16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let data: Vec<f32> = match prec {
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
};
let matrix = |off: usize| lut_ab_matrix(&r, off);
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
let mut stages = Vec::new();
if ty == b"mAB " {
// A curves → CLUT → M curves → matrix → B curves.
@@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
}
}
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
let e = Reader { b: r.slice(off, 48)? };
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = e.s15f16(k * 4)?;
}
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
}
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
// Grid points (16 bytes), precision and padding; the samples follow.
let head = Reader { b: r.slice(off, 20)? };
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = head.u8(k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = head.u8(16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let samples = extent(off, 20, 1)?;
let data: Vec<f32> = match prec {
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
}
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
fn parse_text(d: &[u8]) -> String {
let r = Reader { b: d };
@@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String {
let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() };
let mut best: Option<(usize, usize)> = None;
for k in 0..(n as usize).min(256) {
let o = 16 + k * size as usize;
let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break };
// The record size comes from the file: `k * size` can overflow a 32-bit `usize`.
let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break };
let rec = Reader { b: rec };
let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break };
if best.is_none() || lang == u16::from_be_bytes(*b"en") {
best = Some((off as usize, len as usize));
if lang == u16::from_be_bytes(*b"en") {
@@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String {
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn be(v: u32) -> [u8; 4] {
v.to_be_bytes()
}
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
#[test]
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
let bytes = [0u8; 64];
let r = Reader { b: &bytes };
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
}
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
assert_eq!(extent(16, 3, 12), Ok(52));
}
#[test]
fn a_curve_count_is_checked_before_its_length_is_computed() {
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
// Controls: the identity and a two-point table still parse with their lengths.
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(0));
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(2));
d.extend([0, 0, 0xFF, 0xFF]);
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
}
#[test]
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
// offsets in turn point at the end of a 32-bit file.
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
let mut d = b"mAB \0\0\0\0".to_vec();
d.extend([3, 3, 0, 0]);
for v in [b, matrix, m, clut, a] {
d.extend(be(v));
}
for _ in 0..3 {
d.extend(b"curv\0\0\0\0");
d.extend(be(0));
}
d
};
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
let top = u32::MAX - 8;
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
}
#[test]
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
let record = |lang: &[u8; 2], len: u32, off: u32| {
let mut r = lang.to_vec();
r.extend(b"US");
r.extend(be(len));
r.extend(be(off));
r
};
// Two records of the standard 12 bytes: the English one wins.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(2));
d.extend(be(12));
d.extend(record(b"de", 4, 40));
d.extend(record(b"en", 4, 44));
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
assert_eq!(parse_text(&d), "EN");
// A record size of u32::MAX: the second record lies past any file, so the first is used.
d[12..16].copy_from_slice(&be(u32::MAX));
assert_eq!(parse_text(&d), "DE");
// No records at all.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
d.extend(be(u32::MAX));
assert_eq!(parse_text(&d), "");
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
+15
View File
@@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their
/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> {
let meta = read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "no header-only size probe for deep data"));
}
let pick = pick_part(&meta);
let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?;
if info.color_rank() == 0 {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
}
Ok((info.width, info.height))
}
/// Decode one part of a multi-part file by its index into [`info`]'s list. A file with
/// deep parts is not mixed: the deep decoder composites those instead.
pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result<Image, CodecError> {
+16 -1
View File
@@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
// 0..1 as documented: a value above 2 would make readers (ours too)
// take the data as ADU and scale it by 65535. NaN is written as 0.
let v = d[idx(p, y, x)];
let v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) };
out.extend_from_slice(&v.to_be_bytes());
}
}
}
@@ -632,6 +636,17 @@ mod tests {
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
}
#[test]
fn hdr_float_saves_clamped_and_reopens_as_saved() {
// One value above 2 used to make the reader scale everything by 65535 (all black).
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
let opts = EncodeOptions::default();
let bytes = encode(&img, crate::fidelity::plan(&img, F, &opts), &opts).unwrap();
let back = decode(&bytes, &Limits::default()).unwrap().to_f32_samples().unwrap();
assert!((back[0] - 0.5).abs() < 1e-6, "{back:?}");
assert!((back[1] - 1.0).abs() < 1e-6, "{back:?}");
}
#[test]
fn signed_negative_data_falls_back_to_float() {
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
+20 -2
View File
@@ -37,7 +37,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecErr
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
/// The `image` decoder for `f` with our limits, its header read and its size checked.
fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDecoder + 'a, 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);
let mut il = image::Limits::default();
@@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
reader.limits(il);
let mut dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let (w, h) = dec.dimensions();
limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?;
Ok(dec)
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let mut dec = open(f, bytes, limits)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?;
@@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the decoder's header alone: the
/// same reader, limits and size check, without decoding pixels. AVIF is refused: its decoder
/// decodes the image as it opens.
pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
if f == Format::Avif {
return Err(CodecError::unsupported(f, "no header-only size probe"));
}
let mut dec = open(f, bytes, limits)?;
let (w, h) = dec.dimensions();
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
/// The total is unknown when the blocks end before the trailer or break off.
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
+16 -1
View File
@@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
type Decoder<'a> = image_webp::WebPDecoder<Cursor<&'a [u8]>>;
/// The decoder with its chunks read, the stored size and the output layout, the size checked.
fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> {
let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?;
dec.set_memory_limit(limits.alloc_usize());
let (w, h) = dec.dimensions();
@@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
Ok((dec, w, h, layout))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (mut dec, w, h, layout) = open(bytes, limits)?;
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = alloc_zeroed::<u8>(size)?;
dec.read_image(&mut buf).map_err(err)?;
@@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the
/// same header reads and size checks, without decoding the bitstream.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
let (mut dec, w, h, _) = open(bytes, limits)?;
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
if !opts.webp_lossless {
+2 -1
View File
@@ -195,7 +195,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if lossy_depth {
w.push(W::DepthReduced { from: ss, to: p.sample });
}
if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() {
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() {
w.push(W::RangeClipped);
}
+21
View File
@@ -102,6 +102,27 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
finish_decode(img, o, opts)
}
/// The upright pixel size [`decode`] would return, read from the file's header without decoding
/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same
/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an
/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and
/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size.
pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
let format = detect(bytes).ok_or(CodecError::UnknownFormat)?;
if !caps(format).read {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &Limits::default();
let (w, h, exif) = match format {
Format::WebP => webp::probe(bytes, l)?,
Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?,
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?,
_ => return Err(CodecError::unsupported(format, "no header-only size probe")),
};
let o = exif.as_deref().map_or(1, exif_orientation);
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
}
/// 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)
+6
View File
@@ -115,6 +115,12 @@ fn in_range_float_not_clipped() {
assert!(!w.contains(&W::RangeClipped));
}
#[test]
fn hdr_float_to_fits_clips() {
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
assert!(warns(&img, Format::Fits).contains(&W::RangeClipped));
assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped));
}
#[test]
fn float_to_exr_and_tiff_is_clean() {
for f in [Format::OpenExr, Format::Tiff, Format::Pnm] {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}");
+22
View File
@@ -236,6 +236,7 @@ fn webp_opens_upright() {
// WebP has no gray mode: it comes back as RGB.
let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8);
assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}");
assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}");
}
}
@@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
}
}
/// `probe_size` reads the size `decode` returns from the header alone, for every format it
/// covers, and a file cut short inside its header is refused rather than given a size.
#[test]
fn probe_size_matches_decode() {
let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0);
let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr];
assert!(probed.iter().all(|f| rw_formats().contains(f)));
for format in rw_formats() {
let bytes = encode(&img, format, &EncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap().dimensions();
match probe_size(&bytes) {
Ok(size) => assert_eq!(size, decoded, "{format:?}"),
Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"),
}
if probed.contains(&format) {
assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header");
}
}
assert!(probe_size(&[]).is_err());
}
+666 -141
View File
@@ -79,37 +79,29 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>) {
match adj {
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
Adjustment::Threshold { level } => {
// Compared on the 8-bit grid like Photoshop (avoids float ties).
let t = (level * 255.0).round();
map_rgb(buf, |c| {
let v = if (rgb_to_gray(c) * 255.0).round() >= t { 1.0 } else { 0.0 };
[v; 3]
})
}
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))),
Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]),
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize_in(v, *levels, depth))),
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
// Legacy: contrast scales around 128/255, with brightness added before it when
// contrast is raised and after it when contrast is lowered. Measured on Photoshop
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
let f = legacy_brightness_contrast(*brightness, *contrast);
map_rgb(buf, |px| px.map(&f))
if depth == Some(SampleType::F32) {
let f = legacy_brightness_contrast_hdr(*brightness, *contrast);
map_rgb(buf, |px| px.map(&f));
} else {
let f = legacy_brightness_contrast(*brightness, *contrast);
map_rgb(buf, |px| px.map(&f))
}
}
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
// (a 0..255 ramp pushed through the real app; see log/devlog.md). Unlike the legacy
// linear scale, both are smooth curves that pin pure black and white:
// • Brightness: a line of slope s = 1.375^(b/50) from the origin that rolls off to
// (1,1) via a `v^P` white-anchor term (P grows as |b| grows).
// • Contrast: a symmetric cubic-Hermite S-curve pivoting at 0.5, with endpoint slope
// 1 - c/128 and centre slope 1 + c/128 (near-exact: ≤0.5/255 vs Photoshop).
// Applied brightness-then-contrast. Exact at the sliders' zero and extremes still drift
// on the brightness side (the real curve is a spline); contrast matches closely.
let b = *brightness;
let c = *contrast;
map_rgb(buf, |px| px.map(|v| modern_contrast(modern_brightness(v, b), c).clamp(0.0, 1.0)))
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth.
// Unlike the legacy linear scale, both are smooth curves that pin pure black and white
// (see `modern_brightness` and `modern_contrast`), applied brightness then contrast
// through one lookup table.
let table = modern_lut(*brightness, *contrast);
map_rgb(buf, |px| px.map(|v| lut(&table, v)))
}
Adjustment::Exposure { exposure, offset, gamma } => {
// In linear light: (lin·2^exposure + offset)^(1/gamma), then back
@@ -175,6 +167,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
let hi = if depth == Some(SampleType::F32) { f32::INFINITY } else { 1.0 };
map_rgb(buf, |c| vibrance_px(c, v, s, transfer, hi))
}
Adjustment::ChannelMixer { matrix, monochrome } if depth == Some(SampleType::F32) => {
// 32-bit: each source percentage is cut to 1/1024 (toward zero), the constant counts
// 255/512 of its value, and nothing clips (photoshop corpus rgb32 channel-mixer.psd:
// pure red, green, blue, black and white all exact).
let cut = |v: f32| (v * 1024.0).trunc() / 1024.0;
let m = matrix.map(|row| [cut(row[0]), cut(row[1]), cut(row[2]), row[3] * 255.0 / 512.0]);
map_rgb(buf, |c| {
let mix = |row: &[f32; 4]| row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3];
if *monochrome { [mix(&m[0]); 3] } else { [mix(&m[0]), mix(&m[1]), mix(&m[2])] }
})
}
Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| {
let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0);
if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] }
@@ -184,27 +187,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
let m = photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc);
let set_lum = *preserve_luminosity && !linear_doc;
map_rgb(buf, |c| {
let f = mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v));
// 32-bit samples are linear already, and may go negative.
let f = if linear_doc { mat3_mul(&m, c) } else { mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)) };
let f = if set_lum { photocraft_color::blend::set_lum(f, photocraft_color::blend::lum(c)) } else { f };
f.map(|v| v.clamp(0.0, 1.0))
// 32-bit results aren't clipped (photoshop corpus rgb32 photo-filter.psd: white
// filters to 1.19 red).
if linear_doc { f } else { f.map(|v| v.clamp(0.0, 1.0)) }
})
}
Adjustment::BlackWhite { weights, tint } => {
let hdr = depth == Some(SampleType::F32);
map_rgb(buf, |c| {
let g = black_white_gray(c, weights);
match tint {
// Photoshop tints like the Color blend mode: the tint's hue and saturation at
// the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In
// 32-bit documents nothing clips at 1, as with the blend mode there (photoshop
// corpus rgb32 black-white-tint.psd: white tints to 1.05, 1.00, 0.87).
Some(t) if hdr => photocraft_color::blend::blend_rgb_within(photocraft_color::blend::BlendMode::Color, [g; 3], *t, f32::MAX),
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
None => [g; 3],
}
})
}
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
let g = black_white_gray(c, weights);
match tint {
// Photoshop tints like the Color blend mode: the tint's hue and saturation at the
// grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11.
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
None => [g; 3],
}
}),
Adjustment::GradientMap { stops, reverse, dither } => {
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
for (i, p) in buf.px.iter_mut().enumerate() {
if p[3] <= 0.0 {
continue;
}
let mut t = rgb_to_gray([p[0], p[1], p[2]]);
// Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop
// corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with
// 0.299, 0.587, 0.114).
let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]);
if *reverse {
t = 1.0 - t;
}
@@ -239,13 +254,55 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
}
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at
/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel
/// exact, negative and over-white values included; the 8/16-bit pivot, 128, is 0.43 of a level
/// off there).
fn legacy_brightness_contrast_hdr(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
const PIVOT: f32 = 127.0 / 255.0;
let b = brightness / 255.0;
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
move |v: f32| (v + pre - PIVOT) * k + PIVOT + post
}
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
legacy_brightness_contrast_at(brightness, contrast, LEGACY_PIVOT)
}
/// [`legacy_brightness_contrast`] with contrast pivoting at `pivot`.
fn legacy_brightness_contrast_at(brightness: f32, contrast: f32, pivot: f32) -> impl Fn(f32) -> f32 {
let b = brightness / 255.0;
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
move |v: f32| ((v + pre - pivot) * k + pivot + post).clamp(0.0, 1.0)
}
/// Whether Threshold at `level` turns colour `c` white in a document of `depth` (`None`: unknown,
/// treated as 8-bit). Photoshop splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend
/// modes' `lum`), not the Rec. 601 grey, of the document's whole sample values:
/// - 8 bits: white when that luminosity, rounded to a level with halves down, reaches the level;
/// - 16 bits: white when it reaches the level converted to 16 bits (128 → 16448);
/// - 32 bits: white when it reaches the level.
///
/// Exact on every pixel of psd-tools threshold_rgb.psd, threshold_16bits_rgb.psd and
/// threshold_16bits_grayscale.psd and of the photoshop corpus's rgb16 threshold-128.psd, where the
/// Rec. 601 grey split 28 and 100 pixels of the first two the other way. The sums of
/// `30 R + 59 G + 11 B` over whole samples are exact in f32 (at most 3 276 800).
pub fn threshold_white(c: [f32; 3], level: f32, depth: Option<SampleType>) -> bool {
let t = (level * 255.0).round();
let sum = |q: f32| {
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * q).round());
30.0 * r + 59.0 * g + 11.0 * b
};
match depth {
None | Some(SampleType::U8) => sum(255.0) > 100.0 * t - 50.0,
Some(SampleType::U16) => sum(32768.0) >= 100.0 * (t * 32768.0 / 255.0).round(),
Some(SampleType::F32) => (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) * 255.0 >= t,
}
}
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
@@ -256,11 +313,25 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O
apply_lab(adj, buf, depth);
} else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) {
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
map_rgb(buf, |c| {
let mut ink = rgb_to_cmyk(c);
stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c));
cmyk_to_rgb(ink)
});
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj {
let table = ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
map_rgb(buf, |c| cmyk_to_rgb(rgb_to_cmyk(c).map(|v| ink_through(&table, v))));
} else if cmy_adjustment(adj) {
let mut inks: Vec<[f32; 4]> = buf.px.iter().map(|p| rgb_to_cmyk([p[0], p[1], p[2]])).collect();
apply_cmy(adj, &mut inks);
for (p, ink) in buf.px.iter_mut().zip(&inks) {
if p[3] > 0.0 {
let [r, g, b] = cmyk_to_rgb(*ink);
(p[0], p[1], p[2]) = (r, g, b);
}
}
} else {
map_rgb(buf, |c| {
let mut ink = rgb_to_cmyk(c);
stored_channels(adj, mode, depth, &mut ink);
cmyk_to_rgb(ink)
});
}
} else {
apply_depth(adj, buf, transfer, depth);
}
@@ -282,13 +353,55 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
}
/// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert
/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or
/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128
/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour
/// separated through the document's CMYK profile, so neutrals carry the black that profile
/// generates rather than the file's.
/// and Posterize (each ink, black included, so paper inverts to 400% ink), Threshold (paper or
/// 100% black ink alone) and Brightness/Contrast (each ink's stored value, 1 - ink, through the
/// tone curve; see [`ink_through`]). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4,
/// threshold-128 and brightness-contrast(-legacy) follow these rules on the files' own inks on
/// every pixel; here the inks are the screen colour separated through the document's CMYK
/// profile, so neutrals carry the black that profile generates rather than the file's.
pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool {
stored_channel_adjustment(adj)
stored_channel_adjustment(adj) || cmy_adjustment(adj) || matches!(adj, Adjustment::BrightnessContrast { .. })
}
/// Whether a CMYK document evaluates `adj` on C, M and Y as the complement of an RGB colour,
/// black kept ([`apply_cmy`]): Hue/Saturation (not Colorize, which Photoshop separates again,
/// black and all) and Color Balance (photoshop corpus cmyk16 hue-saturation.psd follows this on
/// every pixel within half a level, cmyk8 within two; color-balance within our RGB Color
/// Balance's own error).
pub fn cmy_adjustment(adj: &Adjustment) -> bool {
matches!(adj, Adjustment::HueSaturation { colorize: false, .. } | Adjustment::ColorBalance { .. })
}
/// A [`cmy_adjustment`] on straight inks (C, M, Y, K; 0 none): the RGB adjustment on
/// `(1 - c, 1 - m, 1 - y)`, black left alone.
pub fn apply_cmy(adj: &Adjustment, inks: &mut [[f32; 4]]) {
let width = i32::try_from(inks.len()).unwrap_or(i32::MAX);
let mut buf = Buffer { rect: crate::Rect::new(0, 0, width, 1), px: inks.iter().map(|i| [1.0 - i[0], 1.0 - i[1], 1.0 - i[2], 1.0]).collect() };
apply_depth(adj, &mut buf, Transfer::Srgb, None);
for (ink, p) in inks.iter_mut().zip(&buf.px) {
for c in 0..3 {
ink[c] = (1.0 - p[c]).clamp(0.0, 1.0);
}
}
}
/// Brightness/Contrast as the lookup table (4096 samples over 0..1) a CMYK ink's stored value,
/// `1 - ink`, goes through ([`ink_through`]): [`modern_lut`], or the legacy linear curve with its
/// contrast pivoting at 128 in ink, which is 127 of 255 stored (photoshop corpus cmyk16
/// brightness-contrast-legacy.psd sits 0.43 of a level off around 128 stored, as a one-level
/// pivot shift times contrast +30's slope predicts).
pub fn ink_brightness_contrast_lut(brightness: f32, contrast: f32, legacy: bool) -> Vec<f32> {
if !legacy {
return modern_lut(brightness, contrast);
}
let curve = legacy_brightness_contrast_at(brightness, contrast, 1.0 - LEGACY_PIVOT);
(0..LUT_SIZE).map(|k| curve(k as f32 / (LUT_SIZE - 1) as f32)).collect()
}
/// One CMYK ink (0: none) through a channel tone curve the way Photoshop applies one in CMYK: to
/// the stored value, `1 - ink`, so raising brightness takes ink away.
pub fn ink_through(table: &[f32], ink: f32) -> f32 {
1.0 - lut(table, 1.0 - ink)
}
/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold.
@@ -298,10 +411,10 @@ pub fn stored_channel_adjustment(adj: &Adjustment) -> bool {
/// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of
/// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`]
/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold
/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and
/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral).
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32], gray: f32) {
/// gives them, or the C, M, Y, K inks (0: none). Lab Invert turns 8-bit bytes `v` into `255 - v`
/// (a becomes -1 - a) and mirrors a and b around neutral at 16 bits (photoshop corpus lab16
/// invert.psd: neutral stays neutral). CMYK Threshold compares [`ink_luma`].
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32]) {
let lab = mode == ColorMode::Lab;
match adj {
Adjustment::Invert => {
@@ -310,7 +423,10 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
*v = if i > 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0);
}
}
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)),
// CMYK posterizes the stored values (1 - ink), which its uneven levels make differ from
// posterizing the ink: psd-tools posterize_cmyk.psd (3 to 21 levels) is exact so.
Adjustment::Posterize { levels } if mode == ColorMode::Cmyk => ch.iter_mut().for_each(|v| *v = 1.0 - posterize_in(1.0 - *v, *levels, depth)),
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize_in(*v, *levels, depth)),
Adjustment::Threshold { level } => {
// On the 8-bit grid, like the RGB threshold.
let t = (level * 255.0).round();
@@ -318,7 +434,9 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
let white = ch.first().is_some_and(|l| (l.clamp(0.0, 1.0) * 255.0).round() >= t);
[if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0]
} else {
[0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }]
let ink = |i: usize| ch.get(i).copied().unwrap_or(0.0);
let black = (ink_luma([ink(0), ink(1), ink(2), ink(3)]) * 255.0).round() < t;
[0.0, 0.0, 0.0, if black { 1.0 } else { 0.0 }]
};
ch.iter_mut().zip(out).for_each(|(v, o)| *v = o);
}
@@ -326,6 +444,14 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
}
}
/// The luma Photoshop's CMYK Threshold compares: Rec. 601 weights on the inks' plain complement,
/// `(1 - c)(1 - k)` and so on, whatever the document's profile (photoshop corpus cmyk8 and cmyk16
/// threshold-128.psd: every pixel on the right side of level 128).
pub fn ink_luma(ink: [f32; 4]) -> f32 {
let paper = 1.0 - ink[3].clamp(0.0, 1.0);
rgb_to_gray([0, 1, 2].map(|i| (1.0 - ink[i].clamp(0.0, 1.0)) * paper))
}
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
/// (b+128)/255.
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
@@ -338,6 +464,49 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
}
/// Neutral a or b as Lab surfaces store it ((0 + 128) / 255).
pub const LAB_NEUTRAL: f32 = 128.0 / 255.0;
/// An adjustment Photoshop evaluates on a Lab document's own channels, as an operation on one
/// pixel's stored L, a, b (normalised as Lab surfaces store them: L / 100, (a + 128) / 255,
/// (b + 128) / 255) in a document of `depth`: Invert and Posterize (each channel), Threshold
/// (split on L, neutral result), Brightness/Contrast (L) and Levels or Curves with Lab records.
/// `None` for any other adjustment. On the stored values colours outside the display gamut keep
/// theirs, where [`apply_in`] can only round-trip them through clipped display RGB.
pub fn lab_stored_op(adj: &Adjustment, depth: Option<SampleType>) -> Option<LabOp<'_>> {
let op: LabOp<'_> = match adj {
Adjustment::Invert | Adjustment::Posterize { .. } => Box::new(move |ch: &mut [f32; 3]| stored_channels(adj, ColorMode::Lab, depth, ch)),
Adjustment::Threshold { level } => {
// Split on L on the 8-bit grid, like the display path (`apply_lab`).
let t = (level * 255.0).round();
Box::new(move |ch: &mut [f32; 3]| {
let white = (ch[0].clamp(0.0, 1.0) * 255.0).round() >= t;
*ch = [if white { 1.0 } else { 0.0 }, LAB_NEUTRAL, LAB_NEUTRAL];
})
}
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
let (b, c, legacy) = (*brightness, *contrast, *legacy);
let legacy_curve = legacy_brightness_contrast(b, c);
Box::new(move |ch: &mut [f32; 3]| {
ch[0] = if legacy { legacy_curve(ch[0]) } else { modern_contrast(modern_brightness(ch[0], b), c).clamp(0.0, 1.0) };
})
}
Adjustment::Levels { space: ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Lab, .. } => {
let luts = tone_luts_depth(adj, depth);
Box::new(move |ch: &mut [f32; 3]| {
for (v, t) in ch.iter_mut().zip(&luts) {
*v = lut(t, *v);
}
})
}
_ => return None,
};
Some(op)
}
/// One pixel's stored L, a, b through an adjustment ([`lab_stored_op`]).
pub type LabOp<'a> = Box<dyn Fn(&mut [f32; 3]) + Send + Sync + 'a>;
/// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any
/// other adjustment).
fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
@@ -345,7 +514,7 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
// L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`).
Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| {
let mut n = lab_norm(c);
stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0);
stored_channels(adj, ColorMode::Lab, depth, &mut n);
lab_denorm(n)
}),
Adjustment::Threshold { level } => {
@@ -355,7 +524,11 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
}
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));
let curve = |v: f32| match &modern {
Some(table) => lut(table, v),
None => legacy_curve(v),
};
// The tone curve on L only. Added as the difference of two round trips (like
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
map_rgb(buf, |c| {
@@ -591,61 +764,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f
}
}
/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11:
/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white).
const BALANCE_LEVELS: f32 = 0.39;
/// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the
/// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue,
/// −100..100).
///
/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at
/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma
/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100:
/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a.
/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is
/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised.
/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch),
/// preserving the one-slider behaviour without summing offsets when multiple sliders move.
/// Shadows and highlights go by the differences between a tone's three sliders:
/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white
/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the
/// other two channels, and two raised together darken only the third).
/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of
/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three
/// tones fold into one Levels per channel (input black point, input white point, midtone gamma),
/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights
/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423).
/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black
/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels,
/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2.
/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white
/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones.
///
/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one
/// channel: within 1 level).
/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter
/// near black without it). When the black point reaches the white point, input
/// at or above the white point is white and the rest black.
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
let [shadows, midtones, highlights] = tones;
let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0);
let white = |v: f32, w: f32| (v / w).min(1.0);
let mut v = v.clamp(0.0, 1.0);
for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] {
let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0));
if preserve_luminosity {
let own = a.get(ch).copied().unwrap_or(0.0);
let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x)));
v = match tone {
0 => black(v, BALANCE_LEVELS * (hi - own)),
1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)),
_ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)),
};
continue;
}
for (src, slider) in sliders.iter().enumerate() {
let a = (slider / 100.0).clamp(-1.0, 1.0);
if a == 0.0 || src != ch {
continue;
}
v = match tone {
1 => v.powf(2f32.powf(-a)),
0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)),
0 => v.powf(2f32.powf(-a / 2.0)),
2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)),
_ => v.powf(2f32.powf(-a / 2.0)),
};
}
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
let (black, white, m) = if preserve_luminosity {
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
} else {
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
};
let (b, w) = (black / 255.0, 1.0 - white / 255.0);
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
if v >= w {
return 1.0;
}
v
if v <= b {
return 0.0;
}
balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0))
}
/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except
/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the
/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop,
/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255).
fn balance_gamma(u: f32, gamma: f32) -> f32 {
let e = 1.0 / gamma.max(0.01);
let power = u.powf(e);
if gamma <= 1.0 {
return power;
}
let s0 = 2f32.powf(gamma);
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
if u0.is_nan() || u0 <= 0.0 || u >= u0 {
return power;
}
let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0);
let (t2, t3) = (t * t, t * t * t);
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
}
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
@@ -823,20 +998,118 @@ fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 {
h * 60.0
}
/// Photoshop posterize: `n` equal input bins over 0..=255, output levels
/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels).
/// Modern Brightness curve (one channel, `brightness` in [-150, 150]). A line of slope
/// `s = 1.375^(b/50)` from the origin, rolled off to (1, 1) by a `v^P` white-anchor term. Pins pure
/// black and white; `b = 0` is the identity. Fit to Photoshop ground truth (see modern B/C above).
/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white,
/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin
/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is
/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is
/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it
/// is the inverse of the curve raised by as much.
///
/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50
/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd
/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give
/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out).
pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
if brightness == 0.0 {
if brightness == 0.0 || !brightness.is_finite() {
return v;
}
let s = 1.375f32.powf(brightness / 50.0);
// Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1).
let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness };
let v = v.clamp(0.0, 1.0);
(s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0)
Brightness::new(brightness).eval(v)
}
/// [`modern_brightness`] for one slider value, its curve's constants worked out once.
struct Brightness {
raise: bool,
s: f32,
e: f32,
x0: f32,
/// The knee's slope over the roll-off's secant (`α` of Fritsch–Carlson); a power roll-off
/// replaces the Hermite when α² + β² > 9.
alpha: f32,
hermite: bool,
}
/// Output level of the Brightness curve's knee.
const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0;
impl Brightness {
fn new(brightness: f32) -> Self {
let b = brightness.abs();
let s = 1.375f32.powf(b / 50.0);
let e = 1.0 / (1.0 + 0.084 * b);
let x0 = (BRIGHTNESS_KNEE / s).min(1.0);
let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6);
let (alpha, beta) = (s / secant, e / secant);
Brightness { raise: brightness > 0.0, s, e, x0, alpha, hermite: alpha * alpha + beta * beta <= 9.0 }
}
fn eval(&self, v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if self.raise {
return self.raised(v);
}
if v <= 0.0 || v >= 1.0 {
return v;
}
// The inverse of the raised curve (monotone on [0, 1]), by bisection.
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..32 {
let mid = 0.5 * (lo + hi);
if self.raised(mid) < v {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
/// The curve raised: the line, then a monotone roll-off to (1, 1).
fn raised(&self, x: f32) -> f32 {
let Brightness { s, e, x0, alpha, hermite, .. } = *self;
let y0 = BRIGHTNESS_KNEE;
if x <= x0 {
return y0.min(s * x);
}
let h = (1.0 - x0).max(1e-6);
let t = ((x - x0) / h).clamp(0.0, 1.0);
let y = if hermite {
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * h * s + (-2.0 * t3 + 3.0 * t2) + (t3 - t2) * h * e
} else {
1.0 - (1.0 - y0) * (1.0 - t).powf(alpha)
};
y.clamp(0.0, 1.0)
}
}
/// Modern Brightness/Contrast as one channel's lookup table (4096 samples over 0..1):
/// [`modern_brightness`] then [`modern_contrast`], as Photoshop applies them.
pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {
let step = |k: usize, n: usize| k as f32 / (n - 1) as f32;
let bright: Vec<f32> = if brightness == 0.0 || !brightness.is_finite() {
(0..LUT_SIZE).map(|k| step(k, LUT_SIZE)).collect()
} else if brightness > 0.0 {
let curve = Brightness::new(brightness);
(0..LUT_SIZE).map(|k| curve.raised(step(k, LUT_SIZE))).collect()
} else {
// Lowered: invert the raised curve, sampled four times finer, in one monotone pass.
let curve = Brightness::new(-brightness);
let fine = 4 * LUT_SIZE;
let ys: Vec<f32> = (0..fine).map(|j| curve.raised(step(j, fine))).collect();
let mut j = 0;
(0..LUT_SIZE)
.map(|k| {
let y = step(k, LUT_SIZE);
while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) {
j += 1;
}
let (y0, y1) = (ys.get(j).copied().unwrap_or(0.0), ys.get(j + 1).copied().unwrap_or(1.0));
let f = if y1 > y0 { ((y - y0) / (y1 - y0)).clamp(0.0, 1.0) } else { 0.0 };
(step(j, fine) + f / (fine - 1) as f32).clamp(0.0, 1.0)
})
.collect()
};
bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect()
}
/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve
@@ -854,6 +1127,8 @@ pub fn modern_contrast(v: f32, contrast: f32) -> f32 {
if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) }
}
/// Photoshop posterize: `n` equal input bins over 0..=255, output levels
/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels).
pub fn posterize(v: f32, levels: u32) -> f32 {
let n = levels.clamp(2, 255) as f32;
let x = (v.clamp(0.0, 1.0) * 255.0).round();
@@ -861,44 +1136,75 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
(bin * 255.0 / (n - 1.0)).floor() / 255.0
}
/// [`posterize`] in a document of `depth` (`None`: unknown, treated as 8-bit). At 16 bits
/// Photoshop bins the 16-bit sample (`⌊v · n / 32768⌋`) and lands on 8-bit levels through a
/// 16-bit step: `⌊b · 32768 / (n - 1)⌋`, converted with `⌊(o · 255 + 16384) / 32768⌋`, so
/// 127.5 comes out as 128 but 42.5 as 42 (8 bits floor them all). Exact on every sample of
/// psd-tools posterize_16bits_rgb.psd and posterize_16bits_grayscale.psd (levels 2 to 21),
/// which the 8-bit rule missed by up to half the range.
pub fn posterize_in(v: f32, levels: u32, depth: Option<SampleType>) -> f32 {
if depth != Some(SampleType::U16) {
return posterize(v, levels);
}
let n = f64::from(levels.clamp(2, 255));
let x = (f64::from(v.clamp(0.0, 1.0)) * 32768.0).round();
let bin = (x * n / 32768.0).floor().min(n - 1.0);
let step = (bin * 32768.0 / (n - 1.0)).floor();
(((step * 255.0 + 16384.0) / 32768.0).floor() / 255.0) as f32
}
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
levels_q(ch, v, None)
}
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
/// the result is a staircase that is exact at every level.
/// levels in three steps, each rounded half up to a whole level: the input-range stretch, the
/// midtone gamma ([`levels_gamma`]) and the output range. So a 44..214 range maps 45, 46, 47, 48
/// → 1.5, 3, 4.5, 6 → 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the
/// steps), and an output range narrower than 0..255 maps neighbouring gamma levels to one level
/// (psd-tools levels_rgb: 43..48 → 58 through a 0.47 channel gamma and the master's 48..255).
/// In a LUT the result is a staircase that is exact at every level.
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
let range = (ch.in_white - ch.in_black).max(1e-6);
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
let quantum = quantum.filter(|q| q.is_finite() && *q >= 1.0);
let whole = |x: f32| match quantum {
Some(q) => (x * q + 0.5).floor() / q,
None => x,
};
let u = match quantum {
Some(q) => {
let v = (v * q).round() / q;
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
}
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
};
let g = ch.gamma.max(0.01);
let t = if g > 1.0 {
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
let power = u.powf(1.0 / g);
let line = 0.93 * 2.0f32.powf(g) * u;
if power <= 1e-6 || line <= 1e-6 {
power.min(line)
} else {
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
}
} else {
u.powf(1.0 / g)
};
ch.out_black + t * (ch.out_white - ch.out_black)
let t = whole(levels_gamma(u, ch.gamma.max(0.01)));
whole(ch.out_black + t * (ch.out_white - ch.out_black))
}
/// Exponent of the shadow toe in [`levels_gamma`] (fitted; see there).
const LEVELS_TOE_POWER: f32 = 2.2;
/// Levels' midtone gamma `g` on `u` in 0..1: `u^(1/g)`, except that above 1, where that power
/// curve is infinitely steep at black, Photoshop starts along a toe `a·u − b·u^q` of slope
/// `a = 2^g` that joins the curve with the same value and slope. `q` = [`LEVELS_TOE_POWER`] is
/// fitted on whole-level oracles at gamma 1.16, 1.22, 1.3, 1.78 and 2.0 (psd-tools levels_rgb
/// and levels_grayscale, the photoshop corpus's rgb8 and gray8 levels.psd, a measured 0..255
/// ramp): all but 2 of their 1118 levels exact, the others within one. Below 1 the power curve is
/// exact (gamma 0.67 and 0.7: every level).
pub fn levels_gamma(u: f32, g: f32) -> f32 {
let c = 1.0 / g;
if g <= 1.0 {
return u.powf(c);
}
let (a, q) = (2f32.powf(g), LEVELS_TOE_POWER);
// Where the toe meets the curve: a·u1 − b·u1^q = u1^c and a − q·b·u1^(q−1) = c·u1^(c−1).
let u1 = ((1.0 - c / q) / (a * (1.0 - 1.0 / q))).powf(1.0 / (1.0 - c));
if u >= u1 || u1 <= 0.0 {
return u.powf(c);
}
let b = (a * u1 - u1.powf(c)) / u1.powf(q);
a * u - b * u.powf(q)
}
/// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result
@@ -1178,6 +1484,41 @@ mod tone_tests {
/// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp
/// (x = 0, 16, …, 240, 255), one row per (brightness, contrast).
/// One pixel through `adj` in a 32-bit document.
fn hdr(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
apply_depth(adj, &mut b, Transfer::Gamma(1.0), Some(SampleType::F32));
[b.px[0][0], b.px[0][1], b.px[0][2]]
}
/// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast
/// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the
/// constant, and the Black & White tint keeps values above 1.
#[test]
fn thirty_two_bit_adjustments_match_photoshop_unclipped() {
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
let bc = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true };
// brightness-contrast-legacy.psd
assert!(near(hdr(&bc, [0.4910, 0.0009, 0.9301]), [0.6280, -0.0721, 1.2553], 2e-4), "{:?}", hdr(&bc, [0.4910, 0.0009, 0.9301]));
// channel-mixer.psd: pure red, green, blue and black, exactly.
let mixer = Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false };
for (c, want) in [
([1.0, 0.0, 0.0], [0.7998047, 0.1245117, 0.0]),
([0.0, 1.0, 0.0], [0.2998047, 0.9243164, 0.1992188]),
([0.0, 0.0, 1.0], [-0.0996094, 0.0249023, 0.6992188]),
([0.0, 0.0, 0.0], [0.0, 0.0249023, 0.0]),
] {
assert!(near(hdr(&mixer, c), want, 1e-6), "{c:?}: {:?}", hdr(&mixer, c));
}
// black-white-tint.psd: white tints past 1.
let bw = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: Some([0.88235295, 0.827451, 0.7019608]) };
assert!(near(hdr(&bw, [1.0; 3]), [1.0522, 0.9973, 0.8718], 2e-4), "{:?}", hdr(&bw, [1.0; 3]));
// 8/16-bit documents still clip.
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]);
apply_depth(&bw, &mut b, Transfer::Srgb, None);
assert!(b.px[0].iter().all(|v| *v <= 1.0));
}
#[test]
fn legacy_brightness_contrast_matches_photoshop() {
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
@@ -1202,6 +1543,48 @@ mod tone_tests {
}
}
/// Pixel → Photoshop's split. 8-bit from psd-tools threshold_rgb.psd: the first three are
/// white though their Rec. 601 grey rounds below 60, and (131, 109, 99) sits exactly on 114.5
/// at level 115 (halves round down). 16-bit from threshold_16bits_rgb.psd and the photoshop
/// corpus's rgb16 threshold-128.psd, whose mid grey 16448 is level 128 in 16 bits.
#[test]
fn threshold_splits_on_the_luminosity_like_photoshop() {
let c8 = |r: u8, g: u8, b: u8| [r, g, b].map(|v| f32::from(v) / 255.0);
for (c, level, white) in
[(c8(87, 51, 31), 60u8, true), (c8(90, 49, 33), 60, true), (c8(68, 57, 50), 60, true), (c8(48, 63, 72), 60, false), (c8(131, 109, 99), 115, false)]
{
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U8)), white, "8-bit {c:?} at {level}");
}
let c16 = |r: u16, g: u16, b: u16| [r, g, b].map(|v| f32::from(v) / 32768.0);
for (c, level, white) in [
(c16(8995, 7196, 6425), 60u8, false),
(c16(11180, 6554, 3984), 60, false),
(c16(16448, 16448, 16448), 128, true),
(c16(16447, 16447, 16447), 128, false),
] {
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U16)), white, "16-bit {c:?} at {level}");
}
// Through the adjustment: neutral black or white.
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [87.0 / 255.0, 51.0 / 255.0, 31.0 / 255.0, 1.0]);
apply_depth(&Adjustment::Threshold { level: 60.0 / 255.0 }, &mut b, Transfer::Srgb, Some(SampleType::U8));
assert_eq!(b.px[0], [1.0, 1.0, 1.0, 1.0]);
}
/// 16-bit sample → Photoshop's 8-bit level, from psd-tools posterize_16bits_rgb.psd: bins
/// split on the 16-bit sample (10794 and 10923 straddle a third of 32768), and the middle of 3
/// levels is 128 where 8 bits give 127, while 21 levels keep 25 for 25.5.
#[test]
fn sixteen_bit_posterize_matches_photoshop() {
let points: [(u32, u16, u8); 9] =
[(3, 10794, 0), (3, 10923, 128), (3, 21845, 128), (3, 21974, 255), (13, 2504, 0), (13, 2542, 21), (13, 7453, 42), (13, 7582, 64), (21, 3341, 25)];
for (levels, x, want) in points {
let got = posterize_in(f32::from(x) / 32768.0, levels, Some(SampleType::U16));
assert!((got * 255.0 - f32::from(want)).abs() < 1e-3, "{levels} levels at {x}: {} vs Photoshop {want}", got * 255.0);
}
// 8 bits keep their rule.
assert_eq!(posterize_in(128.0 / 255.0, 3, Some(SampleType::U8)), posterize(128.0 / 255.0, 3));
}
#[test]
fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
@@ -1254,6 +1637,78 @@ mod tone_tests {
}
}
/// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold
/// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the
/// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with
/// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised
/// highlights (their gammas cancel: a straight line between the two points).
#[test]
fn color_balance_whole_settings_match_photoshop() {
const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]);
let cases: [Case; 6] = [
(
[[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]],
false,
[[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X],
),
(
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
false,
[
[0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255],
[0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255],
[0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255],
],
),
(
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
true,
[
[0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255],
[0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255],
[0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255],
],
),
(
[[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]],
true,
[
[0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255],
[0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255],
[0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255],
],
),
(
[[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]],
true,
[X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]],
),
(
[[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]],
false,
[
[0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255],
[0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255],
[0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255],
],
),
];
for (tones, preserve, want) in cases {
for (ch, row) in want.iter().enumerate() {
for (x, w) in X.iter().zip(row) {
let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32;
assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}");
}
}
}
// Hostile input stays in range.
for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] {
let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v);
assert!((0.0..=1.0).contains(&out), "{v}: {out}");
}
}
#[test]
fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() {
// A shared offset depends on the two extremes, not the sum of moved sliders.
@@ -1525,6 +1980,18 @@ mod tone_tests {
}
}
#[test]
fn gradient_map_follows_the_w3c_luminosity() {
// Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and
// rgb16 gradient-map.psd), not the Rec. 601 grey.
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] {
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b);
assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]);
}
}
#[test]
fn gradient_map_dither_stays_close() {
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
@@ -1600,6 +2067,64 @@ mod tone_tests {
assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3));
}
/// CMYK posterizes the stored value 1 - ink: with 3 levels an ink of 85 (stored 170) lands on
/// the middle step, 128 (stored 127), where posterizing the ink would drop it to none (psd-tools
/// posterize_cmyk.psd, levels 3 to 21, exact so).
#[test]
fn cmyk_posterize_works_on_the_stored_values() {
let mut ink = [85.0 / 255.0; 4];
stored_channels(&Adjustment::Posterize { levels: 3 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ink);
assert!(ink.iter().all(|v| (v * 255.0 - 128.0).abs() < 1e-3), "{ink:?}");
assert_eq!(posterize(85.0 / 255.0, 3), 0.0, "posterizing the ink itself");
}
#[test]
fn cmyk_threshold_compares_the_inks_plain_luma() {
assert_eq!(ink_luma([0.0; 4]), 1.0);
assert_eq!(ink_luma([0.0, 0.0, 0.0, 1.0]), 0.0);
// Two greys either side of level 128 in photoshop corpus cmyk8 threshold-128.psd: black,
// then white (8-bit inks).
let at = |i: [f32; 4]| {
let mut ch = i.map(|v| v / 255.0);
stored_channels(&Adjustment::Threshold { level: 128.0 / 255.0 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ch);
ch
};
assert_eq!(at([144.0, 122.0, 121.0, 35.0]), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(at([131.0, 110.0, 110.0, 19.0]), [0.0; 4]);
}
#[test]
fn cmyk_brightness_contrast_curves_each_ink_from_paper() {
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
// The tone curve runs on each ink's stored value, 1 - ink (photoshop corpus cmyk8
// brightness-contrast.psd and -legacy.psd): raising brightness takes ink away, and paper
// and solid ink stay put.
let red = [0.9, 0.2, 0.1];
for legacy in [false, true] {
let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy };
let table = ink_brightness_contrast_lut(25.0, 30.0, legacy);
let want = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - lut(&table, 1.0 - v)));
assert!(near(in_cmyk(&adj, red), want, 2e-3), "legacy {legacy}");
assert!(near(in_cmyk(&adj, [1.0; 3]), [1.0; 3], 2e-3), "legacy {legacy}: paper");
let solid = cmyk_to_rgb([1.0; 4]);
assert!(near(in_cmyk(&adj, solid), cmyk_to_rgb(rgb_to_cmyk(solid).map(|v| ink_through(&table, v))), 2e-3));
}
// Legacy +25 / +30 on Photoshop's own 16-bit inks (photoshop corpus cmyk16
// brightness-contrast-legacy.psd): contrast pivots at 128 in ink, 127 stored.
let legacy = ink_brightness_contrast_lut(25.0, 30.0, true);
for (ink, ps) in [(139.0, 108.0), (160.0, 137.99), (172.0, 155.13), (183.0, 170.85), (196.0, 189.42), (98.0, 49.42), (128.0, 92.28), (168.0, 149.42)] {
let got = ink_through(&legacy, ink / 255.0) * 255.0;
assert!((got - ps).abs() < 0.1, "ink {ink}: {got} vs Photoshop {ps}");
}
let brighter = Adjustment::BrightnessContrast { brightness: 60.0, contrast: 0.0, legacy: false };
let ink = ink_brightness_contrast_lut(60.0, 0.0, false);
assert!(ink_through(&ink, 0.5) < 0.5 && ink_through(&ink, 0.0) == 0.0 && (ink_through(&ink, 1.0) - 1.0).abs() < 1e-6);
// On the screen colour it would land elsewhere.
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
apply(&brighter, &mut rgb);
assert!(!near(in_cmyk(&brighter, red), [rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]], 0.01));
}
#[test]
fn lab_posterize_steps_l_a_and_b() {
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
+80 -3
View File
@@ -25,6 +25,7 @@ pub mod gradient_fill;
pub mod masks;
pub mod multichannel;
pub mod native;
pub mod native_lab;
pub mod pattern;
pub mod proxy;
pub mod psblend;
@@ -128,6 +129,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
return Buffer::transparent(Rect::EMPTY);
}
// A CMYK stack with adjustment layers the inks carry exactly composites on its inks.
if let Some(buf) = native::render_cmyk(doc, rect, tile) {
return buf;
}
// A Lab stack with adjustment layers that work on L, a and b composites on its stored values.
if let Some(buf) = native_lab::render_lab(doc, rect, tile) {
return buf;
}
cx.set_fx_clip(rect);
if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) {
return photocraft_color::convert::with_cmyk_space(cmyk, || {
@@ -260,7 +269,8 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
/// 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).
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778),
/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]).
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
composite_to_surface(doc, doc.bounds(), fmt, background)
}
@@ -273,6 +283,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
{
return s;
}
if (background.is_none() || paper)
&& let Some(s) = native_lab::flatten_lab(doc, rect, fmt, paper)
{
return s;
}
let mut s = Surface::new(fmt);
let n = fmt.channels();
let empty = s.default_pixel();
@@ -326,6 +341,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
fx_maps: Default::default(),
fx_clip: Default::default(),
doc: None,
layer_keys: Default::default(),
},
advanced::Scope::ROOT,
);
@@ -523,6 +539,60 @@ struct Ctx<'a> {
fx_clip: std::sync::Mutex<Option<Rect>>,
/// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer.
doc: Option<&'a Document>,
/// Effect-map identities of this render's document layers (see [`LayerKeys`]).
layer_keys: LayerKeys,
}
/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends
/// on the layer alone, not on the tile, so it is computed once per layer per render instead of
/// once per tile. Only layers that sit in the document tree are memoised by address: the document
/// is borrowed for the whole render, so no other object can live at such an address. Temporary
/// copies (`advanced::effects_source`) are never in the tree and always take the full path.
#[derive(Default)]
struct LayerKeys {
/// Addresses of every layer in the document tree, collected on first use.
in_doc: std::sync::OnceLock<std::collections::HashSet<usize>>,
ids: std::sync::Mutex<std::collections::HashMap<usize, u64>>,
/// Identities computed (tests count them).
#[cfg(test)]
built: std::sync::atomic::AtomicUsize,
}
impl LayerKeys {
fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 {
let addr = std::ptr::from_ref(layer) as usize;
let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr));
if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) {
return *id;
}
let id = self.compute(layer);
if in_doc {
self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id);
}
id
}
fn compute(&self, layer: &Layer) -> u64 {
use std::hash::Hasher;
#[cfg(test)]
self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut h = std::collections::hash_map::DefaultHasher::new();
layer_identity(layer, &mut h);
h.finish()
}
}
/// Addresses of all layers of `doc` (groups' children included).
fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet<usize> {
let mut out = std::collections::HashSet::new();
let mut todo: Vec<&Layer> = doc.layers.iter().collect();
while let Some(l) = todo.pop() {
out.insert(std::ptr::from_ref(l) as usize);
if let LayerContent::Group(g) = &l.content {
todo.extend(g.children.iter());
}
}
out
}
impl<'a> Ctx<'a> {
@@ -538,6 +608,7 @@ impl<'a> Ctx<'a> {
vector_masks: RenderVectorMasks::default(),
fx_maps: Default::default(),
fx_clip: Default::default(),
layer_keys: Default::default(),
}
}
@@ -1290,11 +1361,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
// adjustment inside no longer reaches the layers beneath).
// adjustment inside no longer reaches the layers beneath), and so it does for a group that
// is the base of a clipping group and holds an adjustment layer (psd-tools
// passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone),
// while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode).
let clip_base_adjusts = clipped.iter().any(|c| c.visible)
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
if let LayerContent::Group(g) = &layer.content
&& layer.blend == BlendMode::PassThrough
&& layer.fill_opacity >= 1.0
&& !effects::has_effects(layer)
&& !clip_base_adjusts
{
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
let has_clipped = clipped.iter().any(|c| c.visible);
@@ -1603,7 +1680,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc<effects::F
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
layer_identity(layer, &mut h);
cx.layer_keys.identity(layer, cx.doc).hash(&mut h);
(region.x0, region.y0, region.x1, region.y1).hash(&mut h);
(cx.light.angle.to_bits(), cx.light.altitude.to_bits()).hash(&mut h);
let key = h.finish();
+110 -34
View File
@@ -171,9 +171,10 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
h.finish()
}
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself.
/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector
/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's
/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges).
pub fn feather_sigma(px: f32) -> f32 {
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
}
@@ -188,37 +189,73 @@ pub fn has_feather(layer: &Layer) -> bool {
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
}
/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just
/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes.
#[derive(Clone, Copy, Debug, PartialEq)]
struct BoxPass {
r: usize,
end: f32,
}
impl BoxPass {
/// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et
/// al. 2011), or `None` when there is nothing to blur.
fn for_sigma(sigma: f32) -> Option<BoxPass> {
if !sigma.is_finite() || sigma <= 0.0 {
return None;
}
// Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow.
let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0;
// The largest whole radius whose box variance r(r+1)/3 is at most v.
let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize;
let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0;
while r > 0 && var(r) > v {
r -= 1;
}
while var(r + 1) <= v {
r += 1;
}
let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0);
let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0));
Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 })
}
}
/// Apply one of the three box passes to a single line. `scratch` preserves the input while
/// the sliding sum writes the output, matching the previous sequential implementation exactly.
fn blur_line(line: &mut [f32], scratch: &mut [f32], r: usize) {
fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) {
if line.is_empty() {
return;
}
let Ok(radius) = isize::try_from(r) else { return };
let Ok(radius) = isize::try_from(pass.r) else { return };
scratch.copy_from_slice(line);
let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize];
let mut acc: f32 = (-radius..=radius).map(get).sum();
let n = (2 * r + 1) as f32;
let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end;
// The sample leaving the window is the next one's low end weight.
let mut below = get(-radius - 1);
for (i, dst) in line.iter_mut().enumerate() {
*dst = acc / n;
let i = i as isize;
acc += get(i + radius + 1) - get(i - radius);
let (above, leaving) = (get(i + radius + 1), get(i - radius));
*dst = (acc + pass.end * (below + above)) / n;
acc += above - leaving;
below = leaving;
}
}
/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used
/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon
/// work here can deadlock when every worker waits on the same OnceLock (#276).
fn blur_rows_chunk(v: &mut [f32], w: usize, r: usize) {
fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) {
let mut scratch = vec![0.0; w];
for row in v.chunks_mut(w) {
for _ in 0..3 {
blur_line(row, &mut scratch, r);
blur_line(row, &mut scratch, pass);
}
}
}
fn blur_rows(v: &mut [f32], w: usize, r: usize) {
fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) {
if w == 0 || v.is_empty() {
return;
}
@@ -229,14 +266,14 @@ fn blur_rows(v: &mut [f32], w: usize, r: usize) {
std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1))
};
if threads == 1 {
blur_rows_chunk(v, w, r);
blur_rows_chunk(v, w, pass);
return;
}
let rows_per_worker = h.div_ceil(threads);
let chunk_len = rows_per_worker.saturating_mul(w);
std::thread::scope(|scope| {
for chunk in v.chunks_mut(chunk_len) {
scope.spawn(move || blur_rows_chunk(chunk, w, r));
scope.spawn(move || blur_rows_chunk(chunk, w, pass));
}
});
}
@@ -256,35 +293,34 @@ fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) {
}
}
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
/// is exact here since the planes extend into constant mask regions). The axes are processed in
/// parallel on bounded OS threads, preserving each line's floating-point operation order.
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is
/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into
/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like
/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels
/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes
/// are processed in parallel on bounded OS threads, preserving each line's floating-point
/// operation order.
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
if sigma <= 0.0 || w == 0 || h == 0 || v.len() != w.saturating_mul(h) {
return;
}
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
if r == 0 {
if w == 0 || h == 0 || v.len() != w.saturating_mul(h) {
return;
}
let Some(pass) = BoxPass::for_sigma(sigma) else { return };
// Reserve before modifying the input so allocation failure can use the sequential fallback
// without leaving a half-blurred mask.
let mut transposed = Vec::new();
if transposed.try_reserve_exact(v.len()).is_err() {
gaussian_sequential(v, w, h, r);
gaussian_sequential(v, w, h, pass);
return;
}
transposed.resize(v.len(), 0.0);
blur_rows(v, w, r);
blur_rows(v, w, pass);
transpose_into(v, &mut transposed, w, h);
blur_rows(&mut transposed, h, r);
blur_rows(&mut transposed, h, pass);
transpose_into(&transposed, v, h, w);
}
/// Allocation-light reference path used if parallel transpose storage cannot be reserved.
fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) {
fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) {
let mut line = Vec::new();
let mut scratch = Vec::new();
let len = w.max(h);
@@ -300,7 +336,7 @@ fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) {
line[i] = if axis { v[i * w + k] } else { v[k * w + i] };
}
for _ in 0..3 {
blur_line(&mut line[..line_len], &mut scratch[..line_len], r);
blur_line(&mut line[..line_len], &mut scratch[..line_len], pass);
}
for i in 0..line_len {
if axis {
@@ -693,14 +729,55 @@ mod tests {
}
}
/// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole
/// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46).
#[test]
fn feather_blur_has_the_exact_variance() {
for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] {
let (w, c) = (801usize, 400usize);
let mut v = vec![0.0f32; w];
v[c] = 1.0;
gaussian(&mut v, w, 1, sigma);
// Within the kernel's reach (the running sums leave ~1e-8 behind further out).
let reach = (8.0 * sigma) as usize + 4;
let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum();
assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt());
assert!((v.iter().sum::<f32>() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total");
}
assert_eq!(BoxPass::for_sigma(0.0), None);
assert_eq!(BoxPass::for_sigma(f32::NAN), None);
assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end)));
}
/// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools
/// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it
/// within 1.5 levels, where whole-pixel boxes were 4 off.
#[test]
fn a_feathered_edge_is_a_gaussian_step() {
// erf, Abramowitz & Stegun 7.1.26 (1.5e-7).
let erf = |x: f64| {
let t = 1.0 / (1.0 + 0.327_591_1 * x.abs());
let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp();
y.copysign(x)
};
let (w, edge, sigma) = (240usize, 120usize, 6.0f32);
let mut v: Vec<f32> = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect();
gaussian(&mut v, w, 1, sigma);
for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) {
let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma);
let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2));
// Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6).
assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}");
}
}
#[test]
fn parallel_gaussian_matches_the_sequential_reference() {
let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0);
let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect();
let mut sequential = parallel.clone();
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
gaussian_sequential(&mut sequential, w, h, r);
let pass = BoxPass::for_sigma(sigma).unwrap();
gaussian_sequential(&mut sequential, w, h, pass);
gaussian(&mut parallel, w, h, sigma);
assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic");
}
@@ -711,12 +788,11 @@ mod tests {
use std::time::Instant;
let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0);
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
let pass = BoxPass::for_sigma(sigma).unwrap();
let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect();
let mut sequential = source.clone();
let start = Instant::now();
gaussian_sequential(&mut sequential, w, h, r);
gaussian_sequential(&mut sequential, w, h, pass);
let sequential_time = start.elapsed();
let mut parallel = source;
let start = Instant::now();
+252 -44
View File
@@ -1,85 +1,255 @@
//! Mode-native flattening of plain CMYK layer stacks (#3778).
//! Mode-native compositing of plain CMYK layer stacks (#3778).
//!
//! The compositor works in display RGB, so flattening a CMYK document through it reads every ink
//! value through the profile and separates it again: even a lone Background comes back with
//! different ink. When every visible layer is a plain raster layer in Normal mode (no masks,
//! effects, clipping or advanced blending), the composite needs no colour conversion at all:
//! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored
//! values and an opaque layer keeps them exactly.
//! values and an opaque layer keeps them exactly. A layer mask (unfeathered) scales the mix, as
//! it does there.
//!
//! Plain adjustment layers that Photoshop evaluates on the inks (Invert, Posterize, Threshold,
//! Brightness/Contrast, Hue/Saturation, Color Balance, and Levels and Curves on CMYK channels)
//! join such a stack too: on the
//! stored inks their rules are exact, where the RGB compositor can only apply them to a separation
//! of its RGB composite, whose black differs from the file's. Such stacks are also rendered for
//! display this way ([`render_cmyk`]); stacks of raster layers alone keep the RGB compositor there.
use photocraft_color::blend::BlendMode;
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::ToneSpace;
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerMask};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::{Buffer, adjust};
/// Process channels of a CMYK pixel.
const INKS: usize = 4;
/// A layer the native path can composite: a visible raster layer the compositor would only blend
/// Normally at its opacity.
fn plain(layer: &Layer) -> Option<&Surface> {
let LayerContent::Raster(s) = &layer.content else { return None };
let simple = layer.blend == BlendMode::Normal
/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask:
/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced
/// blending.
fn simple(layer: &Layer) -> bool {
layer.blend == BlendMode::Normal
&& !layer.clipped
&& layer.mask.as_ref().is_none_or(|m| !m.enabled)
&& !crate::masks::has_feather(layer)
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
&& !crate::effects::has_effects(layer)
&& layer.excluded_channels == 0
&& layer.blend_if.is_default()
&& layer.advanced.is_default()
&& layer.video.is_none()
&& s.format().mode == ColorMode::Cmyk;
simple.then_some(s)
}
/// A layer the native path can composite: a visible raster layer the compositor would only blend
/// Normally at its opacity.
fn plain(layer: &Layer) -> Option<&Surface> {
let LayerContent::Raster(s) = &layer.content else { return None };
(simple(layer) && s.format().mode == ColorMode::Cmyk).then_some(s)
}
/// An adjustment on straight inks, prepared once per composite.
enum InkOp<'a> {
/// Each ink's stored value (1 - ink) through its channel's table: C, M, Y, K
/// (`adjust::ink_through`; Levels, Curves and Brightness/Contrast).
Tables([Vec<f32>; INKS]),
/// Invert, Posterize or Threshold (`adjust::stored_channels`).
Rules(&'a Adjustment),
/// Hue/Saturation or Color Balance on C, M and Y as inverted RGB (`adjust::apply_cmy`).
Cmy(&'a Adjustment),
}
impl InkOp<'_> {
/// The adjusted inks of each pixel of `inks` that has coverage (its fifth value).
fn apply(&self, inks: &[[f32; INKS + 1]], depth: SampleType) -> Vec<[f32; INKS]> {
let mut out: Vec<[f32; INKS]> = inks.iter().map(|d| [d[0], d[1], d[2], d[3]]).collect();
match self {
InkOp::Tables(t) => {
for ink in &mut out {
for (v, table) in ink.iter_mut().zip(t) {
*v = adjust::ink_through(table, *v);
}
}
}
InkOp::Rules(adj) => out.iter_mut().for_each(|ink| adjust::stored_channels(adj, ColorMode::Cmyk, Some(depth), ink)),
InkOp::Cmy(adj) => adjust::apply_cmy(adj, &mut out),
}
out
}
}
/// The ink evaluation of an adjustment layer the native path can composite.
fn ink_op(layer: &Layer, depth: SampleType) -> Option<InkOp<'_>> {
let LayerContent::Adjustment(adj) = &layer.content else { return None };
if !simple(layer) {
return None;
}
match adj {
Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. } => Some(InkOp::Rules(adj)),
_ if adjust::cmy_adjustment(adj) => Some(InkOp::Cmy(adj)),
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
let t = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
Some(InkOp::Tables([t.clone(), t.clone(), t.clone(), t]))
}
Adjustment::Levels { space: ToneSpace::Cmyk, .. } | Adjustment::Curves { space: ToneSpace::Cmyk, .. } => {
Some(InkOp::Tables(adjust::tone_luts_depth(adj, Some(depth))))
}
_ => None,
}
}
/// One step of a native stack, at the layer's opacity × fill, through its mask.
enum Item<'a> {
Layer(&'a Surface, f32, Option<&'a LayerMask>),
Adjust(InkOp<'a>, f32, Option<&'a LayerMask>),
}
/// Groups nested deeper than this keep the RGB compositor.
const MAX_DEPTH: usize = 64;
/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group
/// at full opacity without a mask is inlined: pass-through groups composite their children straight
/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal
/// composites associatively; an adjustment inside an isolated group would only see the group's
/// own content, so such a group keeps the RGB compositor (`adjustments` false).
fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec<Item<'a>>, level: usize) -> Option<()> {
if level > MAX_DEPTH {
return None;
}
for layer in layers.iter().filter(|l| l.visible) {
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
let mask = layer.mask.as_ref().filter(|m| m.enabled);
if let LayerContent::Group(g) = &layer.content {
let open = g.artboard.is_none()
&& alpha >= 1.0
&& mask.is_none()
&& !layer.clipped
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
&& !crate::effects::has_effects(layer)
&& layer.excluded_channels == 0
&& layer.blend_if.is_default()
&& layer.advanced.is_default();
match layer.blend {
BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?,
BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?,
_ => return None,
}
continue;
}
let item = match plain(layer) {
Some(s) => Item::Layer(s, alpha, mask),
None if adjustments => Item::Adjust(ink_op(layer, depth)?, alpha, mask),
None => return None,
};
items.push(item);
}
Some(())
}
/// A CMYK document's visible layers, prepared for compositing on their ink values.
pub struct InkStack<'a> {
items: Vec<Item<'a>>,
depth: SampleType,
}
impl<'a> InkStack<'a> {
/// `None` unless `doc` is CMYK and every visible layer is a plain raster layer, a plain
/// adjustment layer the inks carry exactly, or a group that can be inlined (see the module
/// docs and [`collect`]).
pub fn new(doc: &'a Document) -> Option<Self> {
if doc.mode != ColorMode::Cmyk {
return None;
}
let mut items = Vec::new();
collect(&doc.layers, doc.depth, true, &mut items, 0)?;
Some(InkStack { items, depth: doc.depth })
}
/// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly).
pub fn adjusts(&self) -> bool {
self.items.iter().any(|i| matches!(i, Item::Adjust(..)))
}
/// Straight C, M, Y, K and alpha over `rect`, row-major, into `acc` (`src` and `mv` are
/// scratch).
fn composite(&self, rect: Rect, acc: &mut Vec<[f32; INKS + 1]>, src: &mut Vec<f32>, mv: &mut Vec<f32>) {
let n = rect.width() as usize * rect.height() as usize;
acc.clear();
acc.resize(n, [0.0f32; INKS + 1]);
// The mask's value at pixel `i` (1 without one).
let masked = |mask: Option<&LayerMask>, mv: &mut Vec<f32>| match mask {
Some(m) => m.values_into(rect, mv),
None => {
mv.clear();
mv.resize(n, 1.0);
}
};
for item in &self.items {
match item {
Item::Layer(surf, opacity, mask) => {
let opacity = *opacity;
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
continue;
}
let sf = surf.format();
let sn = sf.channels();
surf.read_region_into(rect, src);
masked(*mask, mv);
for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) {
let sa = if sf.alpha { s.get(INKS).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0);
if sa <= 0.0 {
continue;
}
let oa = sa + d[INKS] * (1.0 - sa);
let keep = d[INKS] * (1.0 - sa);
for (c, v) in s.iter().take(INKS).enumerate() {
// An opaque pixel replaces what is below exactly (no rounding through the mix).
d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa };
}
d[INKS] = oa;
}
}
Item::Adjust(op, opacity, mask) => {
if *opacity <= 0.0 {
continue;
}
masked(*mask, mv);
let adjusted = op.apply(acc, self.depth);
for ((d, k), ink) in acc.iter_mut().zip(mv.iter()).zip(&adjusted) {
let mix = opacity * k.clamp(0.0, 1.0);
if d[INKS] <= 0.0 || mix <= 0.0 {
continue;
}
for (c, v) in ink.iter().enumerate() {
d[c] += (v - d[c]) * mix;
}
}
}
}
}
}
}
/// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the
/// document isn't CMYK or a visible layer needs the full compositor (see the module docs).
/// `paper` flattens onto white paper (no ink) and makes the result opaque.
pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option<Surface> {
if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) {
if fmt.mode != ColorMode::Cmyk {
return None;
}
let mut stack = Vec::new();
for layer in doc.layers.iter().filter(|l| l.visible) {
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
stack.push((plain(layer)?, alpha));
}
let stack = InkStack::new(doc)?;
let mut out = Surface::new(fmt);
if rect.is_empty() {
return Some(out);
}
let n = fmt.channels();
let w = rect.width() as usize;
let rows = crate::band_rows_for(rect.width(), 0);
let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new());
let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut y = rect.y0;
while y < rect.y1 {
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
// Straight C, M, Y, K, alpha per pixel, starting transparent.
acc.clear();
acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]);
for &(surf, opacity) in &stack {
if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
continue;
}
let sf = surf.format();
let sn = sf.channels();
surf.read_region_into(band, &mut src);
for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) {
let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity;
if sa <= 0.0 {
continue;
}
let oa = sa + d[INKS] * (1.0 - sa);
let keep = d[INKS] * (1.0 - sa);
for c in 0..INKS {
// An opaque pixel replaces what is below exactly (no rounding through the mix).
d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa };
}
d[INKS] = oa;
}
}
stack.composite(band, &mut acc, &mut src, &mut mv);
data.clear();
data.resize(acc.len() * n, 0.0);
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
@@ -99,3 +269,41 @@ pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -
out.prune();
Some(out)
}
/// The display composite (straight RGBA, through the document's CMYK profile) of a CMYK document
/// whose stack has adjustment layers the inks carry exactly, worked out on the inks; `None` for
/// any other document, which the RGB compositor renders. Rows of `tile` render in parallel.
pub fn render_cmyk(doc: &Document, rect: Rect, tile: i32) -> Option<Buffer> {
let stack = InkStack::new(doc).filter(InkStack::adjusts)?;
let cmyk = crate::cmyk_space(doc);
let w = rect.width() as usize;
let mut out = Buffer::transparent(rect);
if w == 0 || rect.is_empty() {
return Some(out);
}
let rows = tile.max(1) as usize;
let band = |i: usize, px: &mut [[f32; 4]]| {
let y0 = rect.y0.saturating_add((i * rows) as i32);
let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32));
let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new());
stack.composite(r, &mut acc, &mut src, &mut mv);
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
for (o, p) in px.iter_mut().zip(&acc) {
if p[INKS] > 0.0 {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]);
*o = [r, g, b, p[INKS]];
}
}
});
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px));
}
#[cfg(target_arch = "wasm32")]
for (i, px) in out.px.chunks_mut(w * rows).enumerate() {
band(i, px);
}
Some(out)
}
+253
View File
@@ -0,0 +1,253 @@
//! Lab-native compositing of plain Lab layer stacks.
//!
//! The compositor works in display RGB, so a Lab document's layers are read through `lab_to_srgb`,
//! which clips colours outside the display gamut, and adjustments Photoshop evaluates on L, a and
//! b ([`adjust::lab_stored_op`]) can then only see those clipped colours: an inverted or levelled
//! saturated colour comes out several levels off. When every visible layer is a plain Lab raster
//! layer in Normal mode or such an adjustment layer, the stack is worked out on the stored values
//! instead: Normal mixes L, a and b linearly, as Photoshop does in Lab, and an opaque layer keeps
//! them exactly. A layer mask (unfeathered) scales the mix.
//!
//! Stacks with such adjustment layers render for display this way ([`render_lab`]); flattening
//! ([`flatten_lab`]) keeps the stored values for any plain stack.
use photocraft_color::blend::BlendMode;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::Buffer;
use crate::adjust::{self, LabOp};
/// Colour channels of a Lab pixel.
const LAB: usize = 3;
/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask:
/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced
/// blending.
fn simple(layer: &Layer) -> bool {
layer.blend == BlendMode::Normal
&& !layer.clipped
&& !crate::masks::has_feather(layer)
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
&& !crate::effects::has_effects(layer)
&& layer.excluded_channels == 0
&& layer.blend_if.is_default()
&& layer.advanced.is_default()
&& layer.video.is_none()
}
/// One step of a native stack, at the layer's opacity × fill, through its mask.
enum Item<'a> {
Layer(&'a Surface, f32, Option<&'a LayerMask>),
Adjust(LabOp<'a>, f32, Option<&'a LayerMask>),
}
/// Groups nested deeper than this keep the RGB compositor.
const MAX_DEPTH: usize = 64;
/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group
/// at full opacity without a mask is inlined: pass-through groups composite their children straight
/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal
/// composites associatively; an adjustment inside an isolated group would only see the group's
/// own content, so such a group keeps the RGB compositor (`adjustments` false).
fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec<Item<'a>>, level: usize) -> Option<()> {
if level > MAX_DEPTH {
return None;
}
for layer in layers.iter().filter(|l| l.visible) {
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
let mask = layer.mask.as_ref().filter(|m| m.enabled);
let item = match &layer.content {
LayerContent::Group(g) => {
let open = g.artboard.is_none()
&& alpha >= 1.0
&& mask.is_none()
&& !layer.clipped
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
&& !crate::effects::has_effects(layer)
&& layer.excluded_channels == 0
&& layer.blend_if.is_default()
&& layer.advanced.is_default();
match layer.blend {
BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?,
BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?,
_ => return None,
}
continue;
}
LayerContent::Raster(s) if simple(layer) && s.format().mode == ColorMode::Lab => Item::Layer(s, alpha, mask),
LayerContent::Adjustment(adj) if adjustments && simple(layer) => Item::Adjust(adjust::lab_stored_op(adj, Some(depth))?, alpha, mask),
_ => return None,
};
items.push(item);
}
Some(())
}
/// A Lab document's visible layers, prepared for compositing on their stored L, a and b.
pub struct LabStack<'a> {
items: Vec<Item<'a>>,
}
impl<'a> LabStack<'a> {
/// `None` unless `doc` is Lab and every visible layer is a plain Lab raster layer, an
/// adjustment layer [`adjust::lab_stored_op`] evaluates, or a group that can be inlined (see
/// the module docs and [`collect`]).
pub fn new(doc: &'a Document) -> Option<Self> {
if doc.mode != ColorMode::Lab {
return None;
}
let mut items = Vec::new();
collect(&doc.layers, doc.depth, true, &mut items, 0)?;
Some(LabStack { items })
}
/// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly).
pub fn adjusts(&self) -> bool {
self.items.iter().any(|i| matches!(i, Item::Adjust(..)))
}
/// Straight L, a, b and alpha over `rect`, row-major, into `acc` (`src` and `mv` are scratch).
fn composite(&self, rect: Rect, acc: &mut Vec<[f32; LAB + 1]>, src: &mut Vec<f32>, mv: &mut Vec<f32>) {
let n = rect.width() as usize * rect.height() as usize;
acc.clear();
acc.resize(n, [0.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL, 0.0]);
// The mask's value at each pixel (1 without one).
let masked = |mask: Option<&LayerMask>, mv: &mut Vec<f32>| match mask {
Some(m) => m.values_into(rect, mv),
None => {
mv.clear();
mv.resize(n, 1.0);
}
};
for item in &self.items {
match item {
Item::Layer(surf, opacity, mask) => {
let opacity = *opacity;
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().get(LAB).is_none_or(|a| *a <= 0.0)) {
continue;
}
let sf = surf.format();
let sn = sf.channels();
surf.read_region_into(rect, src);
masked(*mask, mv);
for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) {
let sa = if sf.alpha { s.get(LAB).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0);
if sa <= 0.0 {
continue;
}
let oa = sa + d[LAB] * (1.0 - sa);
let keep = d[LAB] * (1.0 - sa);
for (c, v) in s.iter().take(LAB).enumerate() {
// An opaque pixel replaces what is below exactly (no rounding through the mix).
d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa };
}
d[LAB] = oa;
}
}
Item::Adjust(op, opacity, mask) => {
if *opacity <= 0.0 {
continue;
}
masked(*mask, mv);
for (d, k) in acc.iter_mut().zip(mv.iter()) {
let mix = opacity * k.clamp(0.0, 1.0);
if d[LAB] <= 0.0 || mix <= 0.0 {
continue;
}
let mut lab = [d[0], d[1], d[2]];
op(&mut lab);
for (c, v) in lab.iter().enumerate() {
d[c] += (v - d[c]) * mix;
}
}
}
}
}
}
}
/// The composite of `doc` over `rect` in `fmt`, worked out on stored L, a and b, or `None` when
/// the document isn't Lab, `fmt` isn't, or a visible layer needs the full compositor (see the
/// module docs). `white` flattens onto white (L 100, neutral a and b) and makes the result opaque.
pub fn flatten_lab(doc: &Document, rect: Rect, fmt: PixelFormat, white: bool) -> Option<Surface> {
if fmt.mode != ColorMode::Lab {
return None;
}
let stack = LabStack::new(doc)?;
let mut out = Surface::new(fmt);
if rect.is_empty() {
return Some(out);
}
let n = fmt.channels();
let empty = out.default_pixel();
let rows = crate::band_rows_for(rect.width(), 0);
let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut y = rect.y0;
while y < rect.y1 {
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
stack.composite(band, &mut acc, &mut src, &mut mv);
data.clear();
data.resize(acc.len() * n, 0.0);
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
let a = p[LAB];
if white {
let paper = [1.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL];
for c in 0..LAB {
o[c] = p[c] * a + paper[c] * (1.0 - a);
}
if fmt.alpha {
o[LAB] = 1.0;
}
} else if a <= 0.0 {
o.copy_from_slice(&empty[..n]);
} else {
o[..LAB].copy_from_slice(&p[..LAB]);
if fmt.alpha {
o[LAB] = a;
}
}
}
out.write_region(band, &data);
y = band.y1;
}
out.prune();
Some(out)
}
/// The display composite (straight RGBA) of a Lab document whose stack has adjustment layers that
/// work on L, a and b, worked out on the stored values; `None` for any other document, which the
/// RGB compositor renders. Rows of `tile` render in parallel.
pub fn render_lab(doc: &Document, rect: Rect, tile: i32) -> Option<Buffer> {
let stack = LabStack::new(doc).filter(LabStack::adjusts)?;
let w = rect.width() as usize;
let mut out = Buffer::transparent(rect);
if w == 0 || rect.is_empty() {
return Some(out);
}
let rows = tile.max(1) as usize;
let band = |i: usize, px: &mut [[f32; 4]]| {
let y0 = rect.y0.saturating_add((i * rows) as i32);
let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32));
let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new());
stack.composite(r, &mut acc, &mut src, &mut mv);
for (o, p) in px.iter_mut().zip(&acc) {
if p[LAB] > 0.0 {
let [r, g, b] = photocraft_color::convert::lab_to_srgb([p[0] * 100.0, p[1] * 255.0 - 128.0, p[2] * 255.0 - 128.0]);
*o = [r, g, b, p[LAB]];
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px));
}
#[cfg(target_arch = "wasm32")]
for (i, px) in out.px.chunks_mut(w * rows).enumerate() {
band(i, px);
}
Some(out)
}
+49 -3
View File
@@ -4,8 +4,8 @@
//! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP
//! document would cost seconds and gigabytes.
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_geom::Rect;
use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k).
@@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) {
}
LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)),
LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)),
LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)),
LayerContent::Smart(so) => shrink_smart(so, k),
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
}
}
/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the
/// placement too. A preview that adds a smart filter re-renders the source through the placement;
/// left in full-size document coordinates, that render came out `k` times too large on the proxy
/// canvas, so the Curves preview on a smart object zoomed in (#5766).
fn shrink_smart(so: &mut SmartObject, k: u32) {
so.cache = so.cache.as_ref().map(|s| downsample(s, k));
if let Some(m) = &mut so.filter_mask {
m.surface = downsample(&m.surface, k);
}
let s = 1.0 / f64::from(k);
so.transform = Affine::scale(s).mul(&so.transform);
// Row-major source → document map: scaling the document side scales its first two rows.
if let Some(h) = &mut so.perspective {
for v in h.iter_mut().take(6) {
*v *= s;
}
}
}
/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The
/// selection scales with it, so a filter previewed on the proxy stays inside the (feathered)
/// selection exactly as the full-size result does.
@@ -166,6 +185,33 @@ mod tests {
assert!(proxy_faithful(&doc));
}
/// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its
/// placement, which must shrink with the canvas, cache and filter mask.
#[test]
fn proxy_scales_smart_object_placement() {
use photocraft_doc::SmartSource;
let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
let mut cache = Surface::new(doc.pixel_format());
cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]);
let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) };
let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache));
let mut mask = LayerMask::hide_all();
mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]);
sm.filter_mask = Some(mask);
sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]);
doc.layers.push(Layer::new("smart", LayerContent::Smart(sm)));
let p = proxy_document(&doc, 4);
let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") };
assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] });
assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0]));
assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12));
let mask = &sm.filter_mask.as_ref().unwrap().surface;
assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0));
// The document keeps its full-size placement.
let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") };
assert_eq!(orig.transform.m[4], 16.0);
}
#[test]
fn proxy_scales_the_selection() {
let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
+507 -10
View File
@@ -1,6 +1,6 @@
use super::*;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::{CurvePoint, LevelsChannel};
use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace};
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask};
use photocraft_geom::Size;
@@ -606,6 +606,142 @@ fn a_16_bit_lab_invert_layer_keeps_greys_neutral() {
assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}");
}
/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`.
fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document {
let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth);
let mut l = Layer::raster("bg", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]);
d.layers.push(l);
d
}
/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on
/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast
/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an
/// (unfeathered) mask as linear ink mixes.
#[test]
fn cmyk_ink_adjustments_composite_on_the_documents_inks() {
use photocraft_color::convert::cmyk_to_rgb;
let ink = [0.2, 0.4, 0.6, 0.1];
let rgb = |i: [f32; 4]| {
let c = cmyk_to_rgb(i);
[c[0], c[1], c[2], 1.0]
};
for depth in [SampleType::U8, SampleType::U16] {
let mut d = cmyk_doc(ink, depth);
// Stored inks, as the document's surface rounds them.
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert");
// Half opacity and a half mask mix the inks linearly: a quarter of the way.
d.layers[1].opacity = 0.5;
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]);
d.layers[1].mask = Some(m);
let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1);
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed");
// Brightness/Contrast curves each ink's stored value.
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true);
d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }));
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast");
// Flattening keeps the adjusted inks exactly, with no trip through RGB.
let flat = flatten_to_surface(&d, d.pixel_format(), None);
let got = flat.pixel(2, 2);
for c in 0..4 {
let want = adjust::ink_through(&table, stored[c]);
let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 };
assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]);
}
}
}
/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep
/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks
/// don't carry or a layer they can't composite.
#[test]
fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() {
let r = Rect::new(0, 0, 4, 4);
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers");
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&d, r, 64).is_some());
let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 };
d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure)));
assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry");
d.layers.pop();
d.layers[1].blend = BlendMode::Multiply;
assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode");
d.layers[1].blend = BlendMode::Normal;
let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
rgb.mode = ColorMode::Rgb;
rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document");
// Tiles render alike whatever their size.
let whole = render_tiled(&d, d.bounds(), 64);
assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px);
}
/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the
/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level).
#[test]
fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() {
use photocraft_color::convert::cmyk_to_rgb;
let ranges = photocraft_doc::adjust::HueRange::defaults();
let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() };
let balance =
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
for adj in [hue, balance] {
assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj));
let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16);
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
// The RGB adjustment on the inks' complement, black untouched.
let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]);
adjust::apply(&adj, &mut rgb);
let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]];
assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label());
let c = cmyk_to_rgb(want);
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label());
let flat = flatten_to_surface(&d, d.pixel_format(), None);
let got = flat.pixel(1, 1);
assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label());
assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label());
}
// Colorize separates its colour again (black included), so it stays on the screen colour.
let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges };
assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize));
}
/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches
/// the layers below it, and an isolated group of raster layers composites as its children would.
/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the
/// RGB compositor.
#[test]
fn cmyk_ink_compositing_inlines_open_groups() {
use photocraft_color::convert::cmyk_to_rgb;
let r = Rect::new(0, 0, 4, 4);
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16);
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
d.layers.push(g);
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below");
d.layers[1].blend = BlendMode::Normal;
assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment");
d.layers[1].blend = BlendMode::PassThrough;
d.layers[1].opacity = 0.5;
assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group");
// An isolated group of raster layers, with an adjustment above it, still composites on the inks.
let mut top = Layer::raster("top", d.pixel_format());
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]);
d.layers[1] = Layer::group("raster", vec![top]);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&d, r, 64).is_some());
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer");
}
#[test]
fn cmyk_document_renders_via_rgb() {
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
@@ -1728,6 +1864,43 @@ fn modern_contrast_matches_photoshop() {
}
}
/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes
/// raising it by as much, and odd input never panics.
#[test]
fn modern_brightness_is_monotone_and_lowering_inverts_raising() {
for b in (-150..=150).step_by(5).map(|b| b as f32) {
let mut prev = 0.0f32;
for k in 0..=1020 {
let v = adjust::modern_brightness(k as f32 / 1020.0, b);
assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}");
prev = v;
}
assert!(
(adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5,
"brightness {b} pins black and white"
);
for k in 0..=50 {
let x = k as f32 / 50.0;
let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b);
assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}");
}
}
for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] {
let _ = adjust::modern_brightness(v, 40.0);
let _ = adjust::modern_brightness(0.5, v);
}
assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096);
// The table agrees with the curves it samples, lowered (inverted in one pass) or raised.
for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] {
let t = adjust::modern_lut(b, c);
for k in (0..4096).step_by(37) {
let x = k as f32 / 4095.0;
let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c);
assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]);
}
}
}
#[test]
fn modern_brightness_matches_photoshop() {
let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])];
@@ -1736,8 +1909,48 @@ fn modern_brightness_matches_photoshop() {
for (i, &x) in xs.iter().enumerate() {
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
let err = (got - out[i] as f32).abs();
// The brightness roll-off is a spline; our fit is close but approximate at the top.
assert!(err <= 6.0, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
// Within about a level of Photoshop's (rounded) output everywhere.
assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
}
}
// Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8
// brightness-contrast.psd): what the oracle's levels become.
let oracle = [
(8, 7),
(24, 23),
(48, 49),
(72, 78),
(88, 99),
(104, 120),
(128, 154),
(144, 174),
(160, 192),
(176, 208),
(192, 222),
(200, 228),
(216, 239),
(232, 247),
(240, 250),
(248, 253),
];
for (x, want) in oracle {
let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0;
assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}");
}
// psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness
// each applies, read back through its (exact) contrast curve.
// (brightness, tolerance in levels, [(input level, Photoshop's output level)])
type Measured = (f32, f32, &'static [(u32, f32)]);
let measured: [Measured; 4] = [
(-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]),
(-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]),
(23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]),
(150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]),
];
for (b, tol, points) in measured {
for &(x, want) in points {
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}");
}
}
// Endpoints and the zero slider are exact.
@@ -1748,7 +1961,7 @@ fn modern_brightness_matches_photoshop() {
}
// Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our
// `levels()` matches the real app, including the gamma>1 soft shadow toe (initial slope 2^gamma).
// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma).
#[test]
fn levels_matches_photoshop() {
let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 };
@@ -1764,8 +1977,8 @@ fn levels_matches_photoshop() {
let want = ((x as f32 / 255.0).powi(2) * 255.0).round();
assert_eq!(got, want, "levels gamma 0.5 x={x}");
}
// Endpoints pinned; identity is identity. (The gamma>1 toe's soft-min leaves white within a
// few parts in 1e5 of 1.0 — invisible at any bit depth; exact at 8-bit.)
// Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white
// stays exactly 1.0.)
assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0);
assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0);
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
@@ -1806,6 +2019,86 @@ fn levels_work_on_whole_levels() {
}
}
// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at
// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from:
// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma
// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow
// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54).
// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245.
// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off).
#[test]
fn levels_steps_match_photoshop() {
let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel {
in_black: ib / 255.0,
in_white: iw / 255.0,
gamma,
out_black: ob / 255.0,
out_white: ow / 255.0,
};
let id = LevelsChannel::default();
let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels {
master,
per_channel,
space: Default::default(),
black: LevelsChannel::default(),
};
let run = |adj: &Adjustment, xs: &[u8]| {
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
b.px
};
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
let want: [[u8; 21]; 3] = [
[48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174],
[51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244],
[48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164],
];
for (k, p) in run(&rgb, &xs).iter().enumerate() {
for c in 0..3 {
assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]);
}
}
let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]);
let oracles: [(Adjustment, &[u8], &[u8]); 2] = [
(
single(ch(15.0, 230.0, 1.3, 10.0, 245.0)),
&[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251],
&[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245],
),
(
single(ch(44.0, 214.0, 1.78, 0.0, 255.0)),
&[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214],
&[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255],
),
];
for (adj, xs, want) in &oracles {
for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) {
assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}");
}
}
}
// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a
// jump where it meets the power curve.
#[test]
fn levels_gamma_toe_is_monotone_for_any_gamma() {
for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
let mut last = 0.0f32;
for k in 0..=4096 {
let u = k as f32 / 4096.0;
let t = adjust::levels_gamma(u, g);
assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}");
assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}");
// Never steeper than the toe's initial slope 2^g.
assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}");
last = t;
}
assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white");
}
}
// The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a
// 32-bit document neither range clips and the gamma is a plain power curve mirrored below black:
// 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip.
@@ -2324,9 +2617,10 @@ fn photo_filter_matches_photoshop() {
}
}
// The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply).
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's
// results there are unclipped: above 1 and below 0.
let warming32 = [0.966_445_6, 0.761_806_3, 0.0];
for (v, ps) in [([0.2159, 0.2159, 0.2159], [66.0, 53.0, 40.0]), ([1.0, 1.0, 0.0], [255.0, 251.0, 0.0]), ([1.0, 0.0, 0.0], [255.0, 0.0, 0.0])] {
for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] {
let got = filter(warming32, SampleType::F32, v);
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}");
}
@@ -2530,15 +2824,95 @@ fn lab_documents_invert_and_posterize_l_a_and_b() {
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
// step: L ≈ 50 and a, b ≈ -1, a neutral grey).
// step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey).
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3));
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth)));
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
near3(post, want, 1.0, &format!("{depth:?} posterize"));
}
}
/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces
/// store them), with `adjustments` above it.
fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec<Adjustment>) -> Document {
let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth);
let fmt = doc.pixel_format();
let mut layer = Layer::raster("bg", fmt);
if let Some(s) = layer.surface_mut() {
s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]);
}
doc.layers.push(layer);
doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a))));
doc
}
/// Display RGB of stored Lab `n`.
fn lab_display(n: [f32; 3]) -> [f32; 3] {
photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
}
// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips,
// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and
// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives
// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does.
#[test]
fn lab_adjustments_work_on_the_stored_values() {
let byte = |v: u8| f32::from(v) / 255.0;
let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]);
let p = px(&doc, 1, 1);
let want = lab_display([byte(102), byte(37), byte(217)]);
near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple");
// Through clipped display RGB the result is another colour.
let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)]));
let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]);
assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}");
// Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway.
let ch = LevelsChannel { gamma: 2.0, ..Default::default() };
let levels = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()],
space: ToneSpace::Lab,
black: LevelsChannel::default(),
};
let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]);
if let Some(l) = doc.layers.last_mut() {
l.opacity = 0.5;
}
let p = px(&doc, 1, 1);
let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5;
near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity");
}
// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB
// compositor clipped them on the way through display RGB).
#[test]
fn lab_flatten_keeps_the_stored_values() {
let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0];
for depth in [SampleType::U8, SampleType::U16] {
let doc = lab_stack(lab, depth, vec![]);
let s = flatten_to_surface(&doc, doc.pixel_format(), None);
let got = s.pixel(2, 2);
assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}");
}
}
// Anything the stored values can't carry exactly keeps the RGB compositor.
#[test]
fn lab_stacks_with_other_layers_keep_the_rgb_compositor() {
let lab = [0.6, 0.5, 0.5];
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts()));
let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none());
let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]);
if let Some(l) = multiply.layers.last_mut() {
l.blend = photocraft_color::blend::BlendMode::Multiply;
}
assert!(native_lab::LabStack::new(&multiply).is_none());
let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5));
assert!(native_lab::LabStack::new(&rgb).is_none());
}
#[test]
fn lab_documents_brightness_contrast_moves_lightness_only() {
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
@@ -2594,6 +2968,75 @@ fn lab_documents_gradient_map_follows_lightness() {
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
}
// ---------- Effect-map identity is computed once per layer per render (#4437) ----------
/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled
/// child, and a layer whose mask hides its effects (rendered from a temporary copy).
fn identity_doc(depth: SampleType) -> Document {
let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE);
let fmt = d.pixel_format();
let mut sq = Layer::raster("sq", fmt);
sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))];
d.layers.push(sq);
let mut child = Layer::raster("child", fmt);
child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]);
child.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
let mut group = Layer::group("g", vec![child]);
group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))];
d.layers.push(group);
let mut hidden = Layer::raster("hidden", fmt);
hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]);
hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
hidden.mask = Some(LayerMask::reveal_all());
hidden.advanced.layer_mask_hides_effects = true;
d.layers.push(hidden);
d
}
#[test]
fn memoised_effect_identity_leaves_every_render_unchanged() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let d = identity_doc(depth);
let b = d.bounds();
// One tile for the whole canvas never goes through `prepare_effects`; small tiles do.
purge_effect_cache();
let whole = render_tiled(&d, b, 4096);
purge_effect_cache();
let tiled = render_tiled(&d, b, 16);
let warm = render_tiled(&d, b, 24);
assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile");
assert!(whole.px == warm.px, "{depth:?}: warm differs from cold");
assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw");
// Edits still invalidate: a changed layer must not keep its old maps.
let mut e = d.clone();
e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))];
let edited = render_tiled(&e, b, 16);
purge_effect_cache();
assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit");
assert!(edited.px != whole.px, "{depth:?}: the edit should show");
}
}
#[test]
fn effect_identity_is_built_once_per_layer_not_per_tile() {
let mut d = identity_doc(SampleType::U8);
// Leave out the temporary-copy layer: it is rebuilt per call by design.
d.layers.pop();
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&d, &patterns);
let b = d.bounds();
let tiles = 6 * 6;
let first = render_tiled_with(&d, b, 16, &cx);
let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed);
// sq, the group and its child: three effect layers, however many tiles.
assert_eq!(built, 3, "identity built {built} times over {tiles} tiles");
// A second render with the same context finds all of them memoised.
let second = render_tiled_with(&d, b, 16, &cx);
assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3);
assert!(first.px == second.px);
}
#[test]
fn render_refuses_rects_beyond_the_pixel_cap() {
let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
@@ -2668,3 +3111,57 @@ fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_eff
assert!(!b_fx.is_empty());
assert!(b_fx.contains_rect(&b));
}
#[test]
#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"]
fn modern_lut_timing() {
let t = std::time::Instant::now();
for _ in 0..100 {
std::hint::black_box(adjust::modern_lut(-50.0, 30.0));
}
println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100);
}
#[test]
#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"]
fn cmyk_ink_render_timing() {
// 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the
// RGB compositor (Exposure, which the inks don't carry).
let (w, h) = (6000, 4000);
let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8);
let mut l = Layer::raster("bg", d.pixel_format());
let data: Vec<f32> = (0..(w * h) as usize)
.flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0])
.collect();
l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data);
d.layers.push(l);
for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] {
d.layers.truncate(1);
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
let _ = render(&d, Rect::new(0, 0, 512, 512));
let t = std::time::Instant::now();
let b = render(&d, d.bounds());
println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len());
}
}
// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is
// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no
// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment).
#[test]
fn a_pass_through_clipping_base_keeps_its_adjustments_inside() {
let base = || {
let mut d = doc_white(4, 4);
let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
d.layers.push(group);
d
};
// Unclipped: the invert reaches the white background.
assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1));
// With a layer clipped to the group: the background stays white.
let mut d = base();
let mut clip = Layer::raster("clipped", PixelFormat::RGBA8);
clip.clipped = true;
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1));
}
+161 -3
View File
@@ -45,11 +45,17 @@ pub struct Action {
/// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set: Option<String>,
/// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`].
#[serde(default, skip_serializing_if = "String::is_empty")]
pub color: String,
}
/// Button Mode colours, in Photoshop's order. Empty is no colour.
pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray"];
impl Action {
pub fn new(name: impl Into<String>, steps: Vec<(String, Value)>) -> Self {
Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None }
Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() }
}
/// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved.
@@ -235,7 +241,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
let idx = resolve(&s.actions.list, p, "actions.get")?;
let action = &s.actions.list[idx];
let steps: Vec<Value> = action.steps.iter().map(|(id, params)| json!([id, params])).collect();
Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set}))
Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color}))
}
fn record(s: &mut Session, p: &Value) -> Result<Value> {
@@ -553,6 +559,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"action": name, "index": idx}))
}
fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "actions.setColor";
not_playing(s, cmd)?;
let idx = resolve(&s.actions.list, p, cmd)?;
let Some(color) = p.get("color").and_then(Value::as_str) else {
return Err(bad(cmd, "needs \"color\""));
};
if !BUTTON_COLORS.contains(&color) {
return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, or gray"));
}
let Some(action) = s.actions.list.get_mut(idx) else {
return Err(bad(cmd, "no such action"));
};
action.color = color.to_string();
let name = action.name.clone();
s.actions.touch();
Ok(json!({"action": name, "color": color}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
@@ -580,7 +605,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Get Action",
menu: &[],
shortcut: None,
params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##,
params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is the Button Mode swatch, or empty)"##,
enabled: always,
run: get,
journal: false,
@@ -645,6 +670,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: rename,
journal: false,
},
CommandSpec {
id: "actions.setColor",
label: "Set Action Color",
menu: &[],
shortcut: None,
params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"} → {action, color}. The Button Mode colour. Empty clears it."##,
enabled: always,
run: set_color,
journal: false,
},
]
}
@@ -957,6 +992,109 @@ mod tests {
}
}
#[test]
fn recorded_layer_selection_replays_by_name_on_another_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
let original = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap();
s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap();
// The target name must be captured now, not when recording stops.
s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap();
s.execute("layer.duplicate", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"}));
// Persisted actions must work in an entirely different session too.
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
let target = replay.active().unwrap().active_layer.unwrap();
assert_ne!(original, target);
replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(result["ran"], 3);
assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched");
let active = replay.active().unwrap().active_layer.unwrap();
assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy");
}
}
#[test]
fn named_selection_preserves_modes_and_duplicate_occurrences() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.setProps", json!({"name":"Photo"})).unwrap();
let first = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.group", json!({"name":"Group"})).unwrap();
// A duplicate nested in a group must be found even when that group is collapsed.
let second = s
.edit("child", |doc, active| {
let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format());
let id = child.id;
if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content {
group.children.push(child);
group.expanded = false;
}
Ok(id)
})
.unwrap();
s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap();
s.execute("layer.select", json!({"layer":first.0})).unwrap();
s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0}));
assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"}));
s.execute("layer.select", json!({"layer":first.0})).unwrap();
let r = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]);
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap();
assert!(s.active().unwrap().selected_layers().contains(&second));
// Main's imported .atn steps use a string-valued `layer` and choose the topmost match.
s.execute("layer.select", json!({"layer":"Photo"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![second]);
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap();
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
// Removing an unselected layer does not add it; removing the last target keeps it.
s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap();
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
for p in [
json!({"name":"absent"}),
json!({"name":"Photo"}),
json!({"name":1}),
json!({"name":"Photo","occurrence":-1}),
json!({"name":"Photo","occurrence":1.5}),
json!({"name":"Photo","occurrence":99}),
json!({"name":"Photo","layer":first.0}),
json!({"layer":first.0,"occurrence":0}),
] {
let before = s.active().unwrap().selected_layers();
assert!(s.execute("layer.select", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().selected_layers(), before);
}
s.actions.list.push(Action {
name: "Missing".into(),
steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let before = s.active().unwrap().doc.walk().len();
let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap();
assert_eq!(result["ran"], 0);
assert!(result["failed"]["error"].as_str().unwrap().contains("absent"));
assert_eq!(s.active().unwrap().doc.walk().len(), before);
}
#[test]
fn play_stops_at_the_failing_step_and_reports_it() {
let mut s = Session::new();
@@ -1308,4 +1446,24 @@ mod tests {
let first = &s.documents()[0].doc;
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
}
#[test]
fn button_color_is_one_of_the_photoshop_swatches() {
let mut s = Session::new();
s.execute("actions.record", json!({"name": "Print"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let set = s.execute("actions.setColor", json!({"action": "Print", "color": "blue"})).unwrap();
assert_eq!(set["color"], "blue");
assert_eq!(s.actions.list[0].color, "blue");
assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["color"], "blue");
let saved = serde_json::to_string(&s.actions.list).unwrap();
assert!(saved.contains("blue"));
s.execute("actions.setColor", json!({"action": 0, "color": ""})).unwrap();
assert!(s.actions.list[0].color.is_empty());
assert!(!serde_json::to_string(&s.actions.list).unwrap().contains("color"), "an empty colour is omitted");
for bad in [json!({"action": 0, "color": "pink"}), json!({"action": 0}), json!({"action": 4, "color": "red"}), json!({"color": "red"})] {
assert!(s.execute("actions.setColor", bad).is_err());
}
assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing");
}
}
+100
View File
@@ -527,6 +527,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() {
assert_eq!(rgba(&s, 23, 3), pixels);
}
/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth.
#[test]
fn curves_apply_to_non_colour_targets_at_every_depth() {
let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32);
for depth in DEPTHS {
for target in ["mask", "alpha", "quickMask"] {
let mut s = session(depth, "rgb");
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
let p = match target {
"mask" => {
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"})
}
"alpha" => {
s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap();
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}})
}
_ => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"})
}
};
let edit_target = p.get("target").cloned().unwrap_or(json!(null));
s.edit("levels", |doc, active| {
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap();
surf.fill_rect(area, &[0.5]);
Ok(())
})
.unwrap();
let read = |s: &Session| {
let d = s.active().unwrap();
let layer = d.doc.layer(d.active_layer.unwrap()).unwrap();
let t = match target {
"quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
"alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0),
_ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
};
(rgba(s, 8, 8), t)
};
let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len());
s.execute("image.adjustments.curves", p).unwrap();
let (pixels_after, after) = read(&s);
let case = format!("{target} at {depth}");
assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put");
assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})");
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}");
s.undo();
assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target");
}
}
}
/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's
/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab
/// ignores the composite) or an ink-separated result (CMYK curves the inks).
#[test]
fn curves_on_grey_targets_ignore_the_document_colour_mode() {
let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32);
for mode in ["lab", "cmyk"] {
for depth in [8u64, 16] {
for target in ["mask", "alpha", "quickMask"] {
let mut s = session(depth, mode);
let p = match target {
"mask" => {
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
json!("mask")
}
"alpha" => {
s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap();
json!({"channel": 0})
}
_ => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
json!("quickMask")
}
};
s.edit("levels", |doc, active| {
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap();
surf.fill_rect(area, &[0.25]);
Ok(())
})
.unwrap();
let read = |s: &Session| match target {
"quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
"alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0),
_ => mask_at(s, 8, 8),
};
let before = read(&s);
let pixels = rgba(&s, 8, 8);
s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap();
let after = read(&s);
let case = format!("{mode} {depth}-bit {target}");
assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})");
assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})");
assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put");
}
}
}
}
#[test]
fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
// The same cream on the left (x < 8) and the right (x ≥ 40), grey between.
+21 -1
View File
@@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target());
// An inner command may select a layer that disappears when the compound rolls back.
// Selection, channel targeting and coalescing must roll back with the document (#4975).
let anchor = st.layer_anchor;
let channel_view = st.channel_view.clone();
let coalesce = st.coalesce.clone();
let was_clean = !st.is_dirty();
let journal = s.journal.len();
// Color Sampler points are view state a step may move (crop, rotation): roll them back too, so
// a failed second step can't leave them pinned to pixels the restored document no longer has.
@@ -187,7 +193,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
}
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
st.history = history;
match r {
let result = match r {
Ok(v) => {
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
st.history.set_current_layers(prior);
@@ -198,12 +204,26 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
}
Err(e) => {
st.doc = before;
st.active_layer = prior.active;
st.selected_layers = prior.selected;
st.layer_anchor = anchor;
st.channel_view = channel_view;
st.coalesce = coalesce;
st.color_samplers = samplers;
st.revision += 1;
// A failed compound does not make an otherwise untouched document dirty.
if was_clean {
st.saved_revision = st.revision;
}
st.last_damage = None;
Err(e)
}
};
if result.is_err() {
// The app-wide tool colours/mask mode follow the restored channel target.
s.sync_tools_mask_target();
}
result
}
/// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock).
+63
View File
@@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc<Document> {
s.active().unwrap().doc.clone()
}
#[test]
fn failed_compound_restores_layer_target_history_and_journal() {
let mut s = session(80, 60, 8);
let background = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap();
let selected = s.active().unwrap().active_layer.unwrap();
{
let st = s.active_mut().unwrap();
st.selected_layers = vec![background, selected];
st.layer_anchor = Some(background);
st.coalesce = Some("previous-gesture".into());
}
let st = s.active().unwrap();
let prior_doc = st.doc.clone();
let prior_history = st.history.past_len();
let prior_channel_view = st.channel_view.clone();
let prior_mask_targeted = s.tools.mask_targeted;
let prior_journal = s.journal.len();
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
// Mirrors a scale-bar creation followed by a failed type.create: the
// inner command selects a new layer, which rollback must remove.
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask;
s.sync_tools_mask_target();
Err(bad("test", "synthetic second step failed"))
})
.unwrap_err();
assert!(error.to_string().contains("synthetic second step failed"));
let st = s.active().unwrap();
assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored");
assert_eq!(st.history.past_len(), prior_history, "no undo step remains");
assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled");
assert_eq!(st.active_layer, Some(selected));
assert_eq!(st.selected_layers, vec![background, selected]);
assert_eq!(st.layer_anchor, Some(background));
assert_eq!(st.channel_view, prior_channel_view);
assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel");
assert_eq!(st.coalesce.as_deref(), Some("previous-gesture"));
assert!(st.doc.layer(selected).is_some());
// Subsequent ordinary commands target the original layer, not the
// nonexistent scale bar.
s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap();
assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible);
}
#[test]
fn failed_compound_preserves_a_clean_document() {
let mut s = session(80, 60, 8);
assert!(!s.active().unwrap().is_dirty());
let prior_doc = d(&s);
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
Err(bad("test", "synthetic second step failed"))
})
.unwrap_err();
assert!(error.to_string().contains("synthetic second step failed"));
assert!(Arc::ptr_eq(&prior_doc, &d(&s)));
assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving");
}
#[test]
fn record_measurements_refuses_an_area_too_large_to_measure() {
// #933: with no selection the whole document is measured, and `width * height` overflowed
+45
View File
@@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
}
fn make_from(d: &Descriptor) -> Mapped {
// Duplicate layer/group into a new document: `Mk` with a document class, a layer
// `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN`
// (the copy's name). A `Dplc` with a `T` document reference (duplicate into another
// open document) stays a kept Photoshop step: document indices mean nothing at replay.
if let Some(r) = get_ref(d, "null")
&& ref_class(r).as_deref() == Some("Dcmn")
&& matches!(target(r), Target::Class)
&& let Some(u) = get_ref(d, "Usng")
&& matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection"))
&& let Some(mut p) = layer_target(u)
{
p.insert("document".into(), "new".into());
if let Some(n) = get_text(d, "Nm ") {
p.insert("documentName".into(), n.into());
}
if let Some(n) = get_text(d, "LyrN") {
p.insert("name".into(), n.into());
}
return step("layer.duplicate", Value::Object(p));
}
if let Some(nw) = get_obj(d, "Nw ") {
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
if get_enum(d, "Usng").as_deref() == Some("Slct") {
@@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
let t = pattern_desc(p)?;
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
}
"layer.duplicate" => {
// Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`).
// Anything else falls through to the `Dplc` event below.
if jstr(p, "document") != Some("new") {
return None;
}
let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p));
if let Some(n) = jstr(p, "documentName") {
d = d.with("Nm ", text(n));
}
if let Some(n) = jstr(p, "name") {
d = d.with("LyrN", text(n));
}
d
}
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
"file.new" => {
let mut n = desc("Dcmn");
@@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
match ref_class(r).as_deref() {
Some("Lyr") => {
// A `T` document reference (duplicate into another open document) has no
// replayable meaning here: keep the step verbatim rather than misplay it.
if get_ref(d, "T ").is_some() {
return Mapped::Unsupported { harmless: false };
}
let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } };
if let Some(n) = get_text(d, "Nm ") {
p.insert("name".into(), n.into());
@@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped {
}
fn duplicate_to(id: &str, p: &Value) -> Option<Descriptor> {
// Duplicate into a new document is a `Mk` (handled above); let it fall through.
if id == "layer.duplicate" && jstr(p, "document") == Some("new") {
return None;
}
let mut d = match id {
"layer.duplicate" => desc("null").with("null", layer_ref(p)),
"image.duplicate" => {
@@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[
"layer.new.layer",
"layer.new.group",
"layer.groupLayers",
"layer.duplicate",
"layer.new.layerViaCopy",
"layer.new.layerViaCut",
"layer.newFillLayer.solidColor",
+19 -2
View File
@@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() {
("image.mode.grayscale", json!({})),
("image.mode.bits16", json!({})),
("layer.duplicate", json!({"name": "copy"})),
// Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of
// a recorded Duplicate Layer with Destination: New, plus a named layer).
("layer.duplicate", json!({"document": "new"})),
("layer.duplicate", json!({"document": "new", "documentName": "test new name"})),
("layer.duplicate", json!({"document": "new", "name": "Laer name change"})),
("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})),
("image.duplicate", json!({"mergedOnly": true})),
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
("layer.new.group", json!({"name": "G"})),
@@ -448,6 +454,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))),
),
item("GsnB", "Gaussian Blur", None),
// Duplicate into another open document: the `T` reference means nothing at
// replay, so the step is kept verbatim instead of misplayed as a same-doc copy.
item(
"Dplc",
"Duplicate",
Some(
desc("null")
.with("null", target_ref("Lyr "))
.with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])),
),
),
];
let file = AtnFile {
name: UnicodeString::new_nul("Odd"),
@@ -457,8 +474,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]);
assert_eq!(imp.unsupported.len(), 4);
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
assert_eq!(imp.unsupported.len(), 5);
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
}
+24 -10
View File
@@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result<Bru
if let Some(p) = pattern {
out.texture.pattern = p;
}
if patch.get("size").is_some() && !touches(&["dualBrush", "size"]) {
let new_size = out.size;
out.size = base.size;
out.resize_with_dual_brush(new_size);
}
Ok(out)
}
@@ -188,7 +193,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings>
// A library preset's tips stay on disk until a stroke uses them (#1843).
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
if let Some(v) = num(p, "size") {
b.size = v.max(0.5);
b.resize_with_dual_brush(v.max(0.5));
}
if let Some(v) = num(p, "hardness") {
b.hardness = v.clamp(0.0, 1.0);
@@ -868,11 +873,24 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
v[y * w + x] = ((1.0 - l) * c[3] * sv[(y * w + x) * sc]).clamp(0.0, 1.0);
}
}
let (brush, tw, th) = sampled_brush(cmd, &v, w, h)?;
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "width": tw, "height": th }))
}
/// A sampled-tip brush from a paint-amount map (`w`×`h`, row-major, 0 = no paint, 1 = full):
/// cropped to the painted extent, at most [`MAX_TIP`] px a side. Shared by Define Brush Preset
/// and importing an image file as a brush (`brush.presets.importImage`).
pub(crate) fn sampled_brush(cmd: &str, v: &[f32], w: usize, h: usize) -> Result<(BrushSettings, u32, u32)> {
let at = |x: usize, y: usize| v.get(y * w + x).copied().unwrap_or(0.0);
// Crop to the painted extent.
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for y in 0..h {
for x in 0..w {
if v[y * w + x] > 1e-4 {
if at(x, y) > 1e-4 {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x + 1);
@@ -881,23 +899,19 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if x1 <= x0 || y1 <= y0 {
return Err(bad(cmd, "the selected pixels contain no paint (all white or transparent)"));
return Err(bad(cmd, "the pixels contain no paint (all white or transparent)"));
}
let (tw, th) = ((x1 - x0) as u32, (y1 - y0) as u32);
if tw.max(th) > MAX_TIP {
return Err(bad(cmd, format!("selection too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)")));
return Err(bad(cmd, format!("too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)")));
}
let mut data = Vec::with_capacity((tw * th) as usize);
for y in y0..y1 {
data.extend_from_slice(&v[y * w + x0..y * w + x1]);
data.extend((x0..x1).map(|x| at(x, y)));
}
let tip = GrayTile::from_f32(tw, th, &data);
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "width": tw, "height": th }))
Ok((brush, tw, th))
}
pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
+49
View File
@@ -349,6 +349,38 @@ fn set_brush_merges_fields() {
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
}
#[test]
fn resizing_a_brush_resizes_its_enabled_secondary_tip() {
let mut s = session(64, 64);
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap();
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0));
s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0));
// Editing both diameters in the same patch is an explicit secondary-tip size.
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0));
s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap();
assert_eq!(s.tools.brush.dual_brush.size, 9.0);
// The stroke-level size override must use the same proportional dimensions.
let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap();
assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0));
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0));
}
#[test]
fn an_uncaptured_preset_size_keeps_dual_brush_proportions() {
let mut s = session(32, 32);
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap();
s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap();
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap();
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0));
}
#[test]
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
@@ -910,6 +942,23 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() {
assert!(r.get("fromLive").is_none());
}
#[test]
fn set_brush_takes_a_per_dynamic_curve_and_a_patch_resets_it() {
let mut s = session(20, 20);
let curve = json!([[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
s.execute("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "curve": curve}}})).unwrap();
let with = s.tools.brush.clone();
assert_eq!(with.shape_dynamics.size.curve.points(), vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
assert!(with.transfer.opacity.curve.is_linear(), "other dynamics keep their own curve");
// The panel's diff back to linear is a real patch, and applying it restores the linear curve.
let mut linear = with.clone();
linear.shape_dynamics.size.curve = photocraft_paint::ResponseCurve::LINEAR;
let patch = brush_patch(&with, &linear);
assert_eq!(patch["shapeDynamics"]["size"]["curve"], json!([[0.0, 0.0], [1.0, 1.0]]));
s.execute("tools.setBrush", patch).unwrap();
assert!(s.tools.brush.shape_dynamics.size.curve.is_linear());
}
#[test]
fn last_brush_is_remembered_when_the_revision_moves_only() {
let mut s = session(8, 8);
+7 -4
View File
@@ -96,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
}
if !area.is_empty() {
let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?;
let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?;
src.read_rgba_into(area, &mut rgba);
let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1)));
let mut px = surf.read_region(area);
let weight = limit
.map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1))))
.transpose()?;
let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?;
for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() {
let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]);
@@ -107,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
*c += (v - *c) * w.clamp(0.0, 1.0);
}
}
surf.write_region(area, &px);
surf.try_write_region(area, &px)?;
surf.prune();
}
doc.selection = None;
+19
View File
@@ -164,6 +164,25 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() {
}
}
#[test]
fn channel_paste_allocation_failure_is_transactional() {
for depth in [8, 16, 32] {
let mut s = session(depth);
s.execute("edit.copy", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.entries();
crate::allocation::fail_next_for_test();
let result = paste(&mut s, &json!({}), 1, true, None, "Paste");
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().history.entries(), history);
paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap();
assert!(s.undo());
assert_eq!(*s.active().unwrap().doc, *before);
}
}
#[test]
fn channel_copy_allocation_failure_keeps_clipboard_and_document() {
for depth in [8, 16, 32] {
+81
View File
@@ -182,6 +182,87 @@ pub fn composite_name(mode: ColorMode) -> &'static str {
}
}
/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a
/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IsolatedCurveChannel {
/// Channels-panel name, including a custom alpha name or "{layer} Mask".
pub name: String,
/// Curves param key: empty = composite `points`; otherwise `red`/`green`/…
pub key: &'static str,
/// Index into the tone-space histograms (composite = 0 for RGB/CMYK).
pub hist: usize,
/// Colour components keep their curve tint; masks and alphas do not.
pub tint: bool,
/// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom
/// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335).
pub translate: bool,
/// The gray plane this target edits, whatever the document's colour mode; its surface is
/// what the dialog's histogram must describe (#5335). None for colour components.
pub gray: Option<GraySurface>,
}
/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels
/// even in Lab and CMYK documents (#5335).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GraySurface {
/// The active layer's layer mask.
LayerMask,
/// A saved channel, as an index into [`Document::channels`].
Alpha(usize),
/// The Quick Mask.
QuickMask,
}
/// Isolated Curves channel for the active document, matching [`inject_target`].
pub fn isolated_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
isolated_curve_channel_for(st, false)
}
/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask
/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask
/// must give the same Curves dropdown (#5335).
pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option<IsolatedCurveChannel> {
let doc = &st.doc;
let mode = edit_mode(doc);
match st.channel_view.target {
ChannelTarget::Color(k) => {
let names = color_names(mode);
let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode));
let name = names.get(k)?.to_string();
let key = *keys.get(k)?;
let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) };
Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None })
}
ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel {
name: c.name.clone(),
key: "",
hist: 0,
tint: false,
translate: false,
gray: Some(GraySurface::Alpha(i)),
}),
ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel {
name: "Quick Mask".into(),
key: "",
hist: 0,
tint: false,
translate: true,
gray: Some(GraySurface::QuickMask),
}),
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
_ => None,
}
}
fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
layer.mask.as_ref()?;
let name = format!("{} Mask", layer.name);
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
}
fn edit_mode(doc: &Document) -> ColorMode {
doc.pixel_format().mode
}
+48
View File
@@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() {
assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30));
}
}
/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel.
#[test]
fn isolated_curve_channel_matches_the_channels_panel() {
let mut s = session();
assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel");
s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap();
let green = isolated_curve_channel(s.active().unwrap()).expect("green");
assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true));
assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
let mask = isolated_curve_channel(s.active().unwrap()).expect("mask");
assert_eq!(mask.key, "");
assert!(!mask.tint);
assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name));
s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap();
s.execute("channel.target", json!({"channel": 0})).unwrap();
let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha");
assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false));
assert!(!alpha.translate, "a custom channel name stays exactly as entered");
assert_eq!(alpha.gray, Some(GraySurface::Alpha(0)));
let mut qm = session();
qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask");
assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false));
assert_eq!(row.gray, Some(GraySurface::QuickMask));
}
/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as
/// selecting it in the Channels panel; the Quick Mask keeps priority.
#[test]
fn layers_panel_mask_target_isolates_the_curve_channel() {
let mut s = session();
assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel");
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel");
assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown");
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins");
assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask)));
}
+64 -6
View File
@@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use crate::{EngineError, Result, Session, inspect, pixels};
@@ -143,10 +143,32 @@ pub fn command_specs() -> &'static [CommandSpec] {
pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option<String>) -> Value {
let needs_document =
s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p)));
json!({
let mut entry = json!({
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
"enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document,
})
});
if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) {
o.insert("keywords".into(), json!(k));
}
entry
}
/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and
/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column
/// Marquee, which an agent searches for as "ellipse" or "marquee".
pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] =
&[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")];
/// [`SEARCH_KEYWORDS`] of command `id`, if it has any.
pub fn search_keywords(id: &str) -> Option<&'static str> {
SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words)
}
/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`,
/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case.
pub fn list_entry_matches(entry: &Value, needle: &str) -> bool {
let needle = needle.to_lowercase();
["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle)))
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
@@ -392,6 +414,31 @@ fn doc_mode(s: &Session) -> ColorMode {
s.active().map_or(ColorMode::Rgb, |d| d.doc.mode)
}
/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask,
/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and
/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK
/// (where the curve runs on the inks) (#5335).
fn adjust_mode(s: &Session, p: &Value) -> ColorMode {
if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) }
}
/// Tone params for a gray plane target: the document-space channel records are dropped, because
/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335).
fn gray_tone_params(p: &Value) -> Value {
match p {
Value::Object(m) => {
let mut o = Map::new();
for (k, v) in m {
if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") {
o.insert(k.clone(), v.clone());
}
}
Value::Object(o)
}
other => other.clone(),
}
}
// ---------- the table ----------
#[allow(clippy::too_many_lines)]
@@ -668,7 +715,7 @@ fn build() -> Vec<CommandSpec> {
"Select Layer",
[],
None,
r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
has_doc,
crate::layer_multi_cmds::select
),
@@ -1247,7 +1294,16 @@ fn build() -> Vec<CommandSpec> {
});
let adj = match eyedropped {
Some(adj) => adj,
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
None => {
// A gray plane target keeps the neutral tone space even over params
// carrying Lab or CMYK records (#5335).
let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) {
if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() }
} else {
p.clone()
};
crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))?
}
};
let label = adj.label().to_string();
// A smart object records the adjustment as a smart filter (Photoshop), which
@@ -1261,6 +1317,7 @@ fn build() -> Vec<CommandSpec> {
});
}
v.extend(crate::layer_style::specs());
v.extend(crate::layer_list_cmds::specs());
v.extend(crate::filters::specs());
v.extend(crate::filters_ext::specs());
v.extend(crate::color_to_alpha_cmds::specs());
@@ -1334,6 +1391,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::proof_sim::specs());
v.extend(crate::presets::specs());
v.extend(crate::document_preset_cmds::specs());
v.extend(crate::export_preset_cmds::specs());
v.extend(crate::render_cmds::specs());
v.extend(crate::slice_cmds::specs());
v.extend(crate::web_cmds::specs());
@@ -1550,7 +1608,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
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)));
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt)));
merged.id = group.id;
merged.opacity = group.opacity;
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
+213
View File
@@ -0,0 +1,213 @@
//! The Crop tool's Fill: Content-Aware Fill (#3823). After a rotated crop or one that extends past
//! the canvas, the area the original image doesn't cover (the corners a rotation uncovers, the
//! strips an extension adds) is filled from its surroundings instead of holding the background
//! colour or transparency.
//!
//! Which layer: the Background when the document has one (the layer the crop filled with the
//! background colour), otherwise the active pixel layer (where the crop left transparency). Other
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
//! window around that piece, and only pixels the image covers are sampled.
use photocraft_doc::{Document, LayerId};
use photocraft_geom::Rect;
use rayon::prelude::*;
use crate::{EngineError, Result};
/// Seed for the patch search, so a crop fills the same way every time.
const SEED: u64 = 1;
/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap.
const MAX_PIXELS: u64 = 400_000_000;
/// Where the cropped canvas came from in the original document.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Coverage {
/// Cropped canvas → original document: `[a, b, c, d, e, f]` with
/// `x = a·u + b·v + e`, `y = c·u + d·v + f`.
pub map: [f64; 6],
/// The original canvas size (px).
pub width: f64,
pub height: f64,
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
pub rotated: bool,
}
impl Coverage {
/// An unrotated crop to `r` (original document px).
pub fn crop(r: Rect, width: u32, height: u32) -> Self {
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false }
}
/// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside
/// the original canvas by at least half the pixel's projected size (exact for an unrotated
/// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling
/// blended with the background colour.
fn covered(&self, u: i32, v: i32, margin: f64) -> bool {
let [a, b, c, d, e, f] = self.map;
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin
}
fn margin(&self) -> f64 {
let [a, b, ..] = self.map;
0.5 * (a.abs() + b.abs()) + if self.rotated { 1.0 } else { 0.0 }
}
}
/// Labels of the uncovered pixels: 0 covered, `UNLABELLED` uncovered before labelling, else the
/// component number (1-based).
const UNLABELLED: u32 = u32::MAX;
/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the
/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the
/// caller's history step.
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage) -> Result<()> {
let canvas = doc.bounds();
let (w, h) = (canvas.width(), canvas.height());
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(EngineError::Other(format!("the {w} x {h} crop is too large for Content-Aware fill")));
}
let (w, h) = (w as usize, h as usize);
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?;
if len == 0 {
return Ok(());
}
let margin = cov.margin();
let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?;
labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, l) in row.iter_mut().enumerate() {
if !cov.covered(x as i32, y as i32, margin) {
*l = UNLABELLED;
}
}
});
if !labels.contains(&UNLABELLED) {
return Ok(());
}
if !labels.contains(&0) {
return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()));
}
let id = target_layer(doc, active)?;
let pieces = label_components(&mut labels, w, h)?;
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
let label = |x: i32, y: i32| -> u32 {
if x < 0 || y < 0 {
return 0;
}
(y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0)
};
let n = surf.format().channels().max(1);
let filled: Vec<(Rect, Vec<f32>)> = pieces
.par_iter()
.enumerate()
.map(|(k, bounds)| -> Result<(Rect, Vec<f32>)> {
let k = k as u32 + 1;
let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?;
// The piece's bounds, its pixels from the fill and the rest as they were.
let mut out = surf.read_region(*bounds);
let ww = window.width() as usize;
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if label(x, y) != k || !window.contains(x, y) {
continue;
}
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
d.copy_from_slice(s);
}
}
}
Ok((*bounds, out))
})
.collect::<Result<_>>()?;
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
for (r, px) in filled {
surf.write_region(r, &px);
}
Ok(())
}
/// The Background, else the active pixel layer.
fn target_layer(doc: &Document, active: Option<LayerId>) -> Result<LayerId> {
if let Some(bg) = doc.background() {
return Ok(bg.id);
}
active
.filter(|id| doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_))))
.ok_or_else(|| EngineError::Other("Content-Aware crop fill needs a Background or an active pixel layer".into()))
}
/// Numbers the 8-connected pieces of `UNLABELLED` pixels 1, 2, … and returns each one's bounds.
fn label_components(labels: &mut [u32], w: usize, h: usize) -> Result<Vec<Rect>> {
let mut pieces: Vec<Rect> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
for start in 0..labels.len() {
if labels.get(start) != Some(&UNLABELLED) {
continue;
}
let k = u32::try_from(pieces.len() + 1).ok().filter(|k| *k < UNLABELLED).ok_or_else(|| EngineError::Other("too many uncovered areas".into()))?;
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
if let Some(l) = labels.get_mut(start) {
*l = k;
}
stack.push(start);
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
for dy in -1i64..=1 {
for dx in -1i64..=1 {
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let j = ny as usize * w + nx as usize;
if let Some(l) = labels.get_mut(j)
&& *l == UNLABELLED
{
*l = k;
stack.push(j);
}
}
}
}
pieces.push(Rect::new(x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1));
}
Ok(pieces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn components_are_8_connected_with_their_bounds() {
let (w, h) = (6, 4);
let u = UNLABELLED;
#[rustfmt::skip]
let mut l = vec![
u, 0, 0, 0, 0, u,
0, u, 0, 0, 0, u,
0, 0, 0, 0, 0, 0,
0, 0, 0, u, u, 0,
];
let pieces = label_components(&mut l, w, h).unwrap();
assert_eq!(pieces, vec![Rect::new(0, 0, 2, 2), Rect::new(5, 0, 6, 2), Rect::new(3, 3, 5, 4)]);
assert_eq!(l[7], 1);
assert_eq!(l[11], 2);
assert_eq!(l[22], 3);
}
#[test]
fn unrotated_coverage_is_exact() {
let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4);
let m = c.margin();
assert!(!c.covered(1, 0, m), "x = -1");
assert!(c.covered(2, 0, m), "x = 0");
assert!(c.covered(6, 0, m), "x = 4");
assert!(!c.covered(7, 0, m), "x = 5");
}
}
+101
View File
@@ -835,6 +835,44 @@ fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
)
}
/// Edit Selected Guides (the guide context menu): change one guide's orientation, position and
/// colour in one history step. A guide that keeps its orientation keeps its index; one that turns
/// goes to the end of the other list. Without `color` it keeps its own colour, or none.
fn edit_guide(s: &mut Session, p: &Value) -> Result<Value> {
let orientation = |key: &str| match p.get(key).map(|v| v.as_str()) {
None => Ok(None),
Some(Some("horizontal")) => Ok(Some(false)),
Some(Some("vertical")) => Ok(Some(true)),
Some(_) => Err(EngineError::Other(format!("`{key}` must be \"horizontal\" or \"vertical\""))),
};
let from = orientation("orientation")?.unwrap_or(false);
let to = orientation("newOrientation")?.unwrap_or(from);
let index = p.get("index").and_then(Value::as_u64).ok_or(EngineError::Other("missing `index`".into()))?;
let pos = guide_position(s, p, to)?;
let color = guide_color(p)?;
s.edit("Edit Guide", |doc, _| {
let guides = &mut doc.guides;
let list = if from { &guides.vertical } else { &guides.horizontal };
let i = usize::try_from(index).ok().filter(|i| *i < list.len()).ok_or(EngineError::Other("no such guide".into()))?;
let old = list[i];
let at = pos.unwrap_or(old);
let rgb = color.or_else(|| guides.color(from, old));
if from == to {
let list = if from { &mut guides.vertical } else { &mut guides.horizontal };
list[i] = at;
} else {
(if from { &mut guides.vertical } else { &mut guides.horizontal }).remove(i);
(if to { &mut guides.vertical } else { &mut guides.horizontal }).push(at);
}
guides.prune_colors();
if rgb.is_some() {
guides.set_color(to, at, rgb);
}
Ok(())
})?;
Ok(Value::Null)
}
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
@@ -1014,6 +1052,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| guide_cmd(s, p, "move")
),
spec!(
"view.editGuide",
"Edit Guide",
&[],
None,
r##"{"orientation":"horizontal|vertical","index":n,"newOrientation":"horizontal|vertical"?,"position":px or "<number><unit>"?,"color":name or "#rrggbb"?} (Edit Selected Guides: units and colours as for view.newGuide; % is of the new orientation's extent; whatever is left out stays as it was)"##,
has_doc,
edit_guide
),
spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")),
spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")),
spec!(
@@ -2082,6 +2129,60 @@ mod tests {
assert_eq!(s.active().unwrap().history.can_undo(), undo_before);
}
#[test]
fn editing_a_guide_changes_what_is_given_in_one_undoable_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap();
let guides = |s: &Session| s.active().unwrap().doc.guides.clone();
// Position only: the guide keeps its index and its own colour.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "50%"})).unwrap();
let g = guides(&s);
assert_eq!(g.vertical, vec![10.0, 200.0, 30.0]);
assert_eq!((g.color(true, 20.0), g.color(true, 200.0)), (None, Some([0xff, 0x00, 0xff])));
// Colour only: an uncoloured guide gets one; the position stays.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "color": "#123456"})).unwrap();
assert_eq!(guides(&s).color(true, 10.0), Some([0x12, 0x34, 0x56]));
// Turning it: % is of the new orientation's extent, and the colour goes along.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "newOrientation": "horizontal", "position": "25%"})).unwrap();
let g = guides(&s);
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![10.0, 30.0], vec![50.0]));
assert_eq!((g.color(true, 200.0), g.color(false, 50.0)), (None, Some([0xff, 0x00, 0xff])));
// One step each: undo restores the vertical guide with its colour.
s.undo();
let g = guides(&s);
assert_eq!((g.vertical.clone(), g.horizontal.is_empty()), (vec![10.0, 200.0, 30.0], true));
assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff]));
}
#[test]
fn bad_guide_edits_are_refused_without_a_history_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": "green"})).unwrap();
let before = s.active().unwrap().doc.guides.clone();
for bad in [
json!({}),
json!({"orientation": "vertical"}),
json!({"orientation": "vertical", "index": 1}),
json!({"orientation": "horizontal", "index": 0}),
json!({"orientation": "vertical", "index": -1}),
json!({"orientation": "vertical", "index": "0"}),
json!({"orientation": "vertical", "index": u64::MAX}),
json!({"orientation": "diagonal", "index": 0}),
json!({"orientation": "vertical", "index": 0, "newOrientation": 3}),
json!({"orientation": "vertical", "index": 0, "position": "far"}),
json!({"orientation": "vertical", "index": 0, "position": 1e12}),
json!({"orientation": "vertical", "index": 0, "color": "puce"}),
] {
assert!(s.execute("view.editGuide", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.active().unwrap().doc.guides, before);
assert_eq!(s.active().unwrap().history.undo_label(), Some("New Guide"));
}
#[test]
fn bad_guide_positions_are_refused_without_a_guide() {
let mut s = Session::new();
+290
View File
@@ -0,0 +1,290 @@
//! Named Export As configurations. They persist with the other presets in the preferences
//! document; applying one only returns settings, and the export itself stays in Export As.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::presets::{always, bad, req_str};
use crate::{Result, Session};
const MAX_PRESETS: usize = 256;
const MAX_PX: u32 = 300_000;
const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportPresetSettings {
pub format: String,
pub quality: u8,
pub lossless: bool,
pub transparency: bool,
pub scale: f64,
/// Output pixels. Ignored when [`Self::sizing`] is `"scale"`.
pub width: u32,
pub height: u32,
pub lock_aspect: bool,
/// `"scale"` uses [`Self::scale`] on the current document; `"pixels"` uses width and height.
pub sizing: String,
/// `"none"` drops XMP and EXIF/resolution, `"exif"` keeps EXIF and resolution, `"all"` keeps both.
pub metadata: String,
/// `"document"` keeps the document profile; `"srgb"` converts to sRGB before export.
pub profile: String,
pub embed_profile: bool,
/// `"auto"`, `"24"` or `"32"`.
pub tga_bits: String,
}
impl ExportPresetSettings {
fn validate(&self) -> std::result::Result<(), String> {
if !FORMATS.contains(&self.format.as_str()) {
return Err(format!("format must be one of {}", FORMATS.join(", ")));
}
if !(1..=100).contains(&self.quality) {
return Err("quality must be within 1..=100".into());
}
if !self.scale.is_finite() || !(1.0..=1000.0).contains(&self.scale) {
return Err("scale must be within 1..=1000 percent".into());
}
if !matches!(self.sizing.as_str(), "scale" | "pixels") {
return Err("sizing must be scale or pixels".into());
}
if self.sizing == "pixels" && (!(1..=MAX_PX).contains(&self.width) || !(1..=MAX_PX).contains(&self.height)) {
return Err(format!("width and height must be within 1..={MAX_PX} pixels"));
}
if !matches!(self.metadata.as_str(), "none" | "exif" | "all") {
return Err("metadata must be none, exif or all".into());
}
if !matches!(self.profile.as_str(), "document" | "srgb") {
return Err("profile must be document or srgb".into());
}
if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") {
return Err("tgaBits must be auto, 24 or 32".into());
}
Ok(())
}
fn from_params(p: &Value, cmd: &str) -> Result<Self> {
let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?;
let defaults = json!({
"format": "png",
"quality": 85,
"lossless": false,
"transparency": true,
"scale": 100.0,
"width": 0,
"height": 0,
"lockAspect": true,
"sizing": "scale",
"metadata": "none",
"profile": "document",
"embedProfile": true,
"tgaBits": "auto"
});
if let Some(defaults) = defaults.as_object() {
for (k, v) in defaults {
fields.entry(k.clone()).or_insert_with(|| v.clone());
}
}
for key in ["width", "height"] {
if let Some(n) = fields.get(key).and_then(Value::as_f64) {
if !n.is_finite() || n < 0.0 || n > f64::from(MAX_PX) {
return Err(bad(cmd, format!("`{key}` must be within 0..={MAX_PX} pixels")));
}
fields.insert(key.into(), json!(n.round() as u32));
}
}
if let Some(q) = fields.get("quality").and_then(Value::as_f64) {
if !q.is_finite() || !(1.0..=100.0).contains(&q) {
return Err(bad(cmd, "`quality` must be within 1..=100"));
}
fields.insert("quality".into(), json!(q.round() as u8));
}
let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?;
settings.validate().map_err(|e| bad(cmd, e))?;
Ok(settings)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportPreset {
pub name: String,
pub settings: ExportPresetSettings,
}
fn checked_name(name: &str) -> std::result::Result<String, String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME {
return Err("Preset names must contain 1–255 characters after trimming".into());
}
Ok(name.into())
}
/// Read entries independently so a damaged preset cannot discard other presets or preferences.
pub(crate) fn load(value: Value) -> Vec<ExportPreset> {
let Some(entries) = value.as_array() else { return Vec::new() };
let mut presets: Vec<ExportPreset> = Vec::new();
for entry in entries.iter().take(MAX_PRESETS) {
let Ok(mut preset) = serde_json::from_value::<ExportPreset>(entry.clone()) else { continue };
let Ok(name) = checked_name(&preset.name) else { continue };
if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) {
continue;
}
preset.name = name;
presets.push(preset);
}
presets
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.export.presets.save";
let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?;
if s.presets.export_presets.len() >= MAX_PRESETS && s.presets.export_presets.iter().all(|pr| !pr.name.eq_ignore_ascii_case(&name)) {
return Err(bad(CMD, format!("At most {MAX_PRESETS} export presets can be saved")));
}
let settings = ExportPresetSettings::from_params(p.get("settings").unwrap_or(&Value::Null), CMD)?;
let preset = ExportPreset { name: name.clone(), settings };
if let Some(existing) = s.presets.export_presets.iter_mut().find(|pr| pr.name.eq_ignore_ascii_case(&name)) {
*existing = preset.clone();
} else {
s.presets.export_presets.push(preset.clone());
}
s.presets_changed();
Ok(json!({"preset": preset}))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.export.presets.get";
let name = req_str(p, "name", CMD)?;
let preset = s.presets.export_presets.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?;
Ok(json!({"preset": preset}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.export.presets.delete";
let name = req_str(p, "name", CMD)?;
let index = s.presets.export_presets.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?;
s.presets.export_presets.remove(index);
s.presets_changed();
Ok(json!({"deleted": name}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "file.export.presets.list",
label: "List Export Presets",
menu: &[],
shortcut: None,
params: "{}",
enabled: always,
run: |s, _| Ok(json!({"presets": s.presets.export_presets})),
journal: false,
},
CommandSpec {
id: "file.export.presets.get",
label: "Get Export Preset",
menu: &[],
shortcut: None,
params: r#"{"name":str} -> {preset} (settings for Export As; nothing is exported)"#,
enabled: always,
run: get,
journal: false,
},
CommandSpec {
id: "file.export.presets.save",
label: "Save Export Preset",
menu: &[],
shortcut: None,
params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#,
enabled: always,
run: save,
journal: false,
},
CommandSpec {
id: "file.export.presets.delete",
label: "Delete Export Preset",
menu: &[],
shortcut: None,
params: r#"{"name":str}"#,
enabled: always,
run: delete,
journal: false,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(sizing: &str) -> Value {
json!({
"format": "jpg",
"quality": 70,
"lossless": false,
"transparency": false,
"scale": 50,
"width": 640,
"height": 480,
"lockAspect": true,
"sizing": sizing,
"metadata": "exif",
"profile": "srgb",
"embedProfile": false,
"tgaBits": "24"
})
}
#[test]
fn presets_round_trip_through_the_preferences_document() {
let mut s = Session::new();
s.execute("file.export.presets.save", json!({"name": " Web JPEG ", "settings": settings("pixels")})).unwrap();
let again = s.execute("file.export.presets.save", json!({"name": "web jpeg", "settings": settings("scale")})).unwrap();
assert_eq!(again["preset"]["settings"]["sizing"], "scale");
assert_eq!(s.presets.export_presets.len(), 1, "saving the same name replaces it");
let raw = s.prefs_to_json();
let mut restored = Session::new();
restored.load_prefs_json(&raw).unwrap();
let listed = restored.execute("file.export.presets.list", json!({})).unwrap();
let presets = listed["presets"].as_array().unwrap();
assert_eq!(presets.len(), 1);
assert_eq!(presets[0]["name"], "web jpeg");
assert_eq!(presets[0]["settings"]["format"], "jpg");
assert_eq!(presets[0]["settings"]["width"], 640);
assert_eq!(presets[0]["settings"]["embedProfile"], false);
assert_eq!(presets[0]["settings"]["metadata"], "exif");
let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap();
assert_eq!(got["preset"]["settings"]["profile"], "srgb");
restored.execute("file.export.presets.delete", json!({"name": "web jpeg"})).unwrap();
assert!(restored.execute("file.export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().is_empty());
let raw = restored.prefs_to_json();
let mut after = Session::new();
after.load_prefs_json(&raw).unwrap();
assert!(after.presets.export_presets.is_empty());
}
#[test]
fn bad_presets_are_rejected_and_a_damaged_entry_is_dropped() {
let mut s = Session::new();
assert!(s.execute("file.export.presets.save", json!({})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": " ", "settings": settings("scale")})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"format": "bmp"}})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"sizing": "pixels", "width": 0, "height": 10}})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err());
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err());
assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err());
assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err());
let loaded = load(json!([
{"name": "ok", "settings": {"format": "png", "quality": 85, "lossless": false, "transparency": true, "scale": 100.0, "width": 0, "height": 0, "lockAspect": true, "sizing": "scale", "metadata": "none", "profile": "document", "embedProfile": true, "tgaBits": "auto"}},
{"name": "broken", "settings": {"format": "nope"}},
"not an object"
]));
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "ok");
}
}
+155 -9
View File
@@ -457,6 +457,20 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given.
/// EXR/HDR rarely states its colour space, so io tags it with the linear-sRGB default.
/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1,
/// linear) — the working space ACES render pipelines write. Only the io default is
/// replaced; an embedded or EXIF-declared profile is kept untouched.
fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg {
return;
}
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
if doc.icc_profile.as_ref().map(|v| v.as_slice()) == Some(linear.as_slice()) {
doc.icc_profile = Some(photocraft_cms::Builtin::AcesCg.profile().to_bytes());
}
}
pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option<String>) -> Result<Value> {
crate::allocation::checkpoint("importing document")?;
let decode_name = match as_ext {
@@ -465,6 +479,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&
};
let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?;
let mut doc = r.document;
tag_exr_colorspace(s, &decode_name, &mut doc);
let path = match template_name(s, name) {
Some(untitled) => {
doc.name = untitled;
@@ -930,13 +945,106 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
}
}
/// One input of a batch run: the file, and the folder it sits in relative to the input folder
/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BatchInput {
pub path: String,
pub rel_dir: String,
}
/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders").
pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32;
/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run
/// that silently stops partway.
pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000;
/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch,
/// Image Processor): with `"includeSubfolders": true` a folder input also yields the images in its
/// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as
/// the dialog's default `<input>/batch` is) is not walked, so a rerun doesn't process its results.
pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result<Vec<BatchInput>> {
let recurse = match p.get("includeSubfolders") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }),
};
match p.get("input").or_else(|| p.get("files")) {
Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd),
_ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()),
}
}
/// The images under `dir` and its subfolders: each folder's files sorted by name, then its
/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and
/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip`
/// (the output folder) is left out.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
use std::path::{Path, PathBuf};
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let root = Path::new(dir);
let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?;
let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) };
let mut seen = std::collections::HashSet::new();
seen.insert(root_canon);
let mut out = Vec::new();
// Depth first with an explicit stack (no recursion): (folder, relative folder, depth).
let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)];
while let Some((folder, rel, depth)) = stack.pop() {
let shown = folder.to_string_lossy().into_owned();
// The top folder must be readable; a subfolder that isn't is skipped.
let files = match list_images(&shown) {
Ok(f) => f,
Err(e) if depth == 0 => return Err(e),
Err(_) => continue,
};
if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") });
}
out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() }));
if depth >= MAX_SUBFOLDER_DEPTH {
continue;
}
let Ok(rd) = std::fs::read_dir(&folder) else { continue };
let mut subs: Vec<(String, PathBuf)> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p)))
.collect();
subs.sort();
// Pushed in reverse so the stack pops them in name order.
for (name, sub) in subs.into_iter().rev() {
let Some(c) = canon(&sub) else { continue };
if skip.as_ref() == Some(&c) || !seen.insert(c) {
continue;
}
let rel = if rel.is_empty() { name } else { format!("{rel}/{name}") };
stack.push((sub, rel, depth + 1));
}
}
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format`
/// (`"same"` keeps the input's extension). Errors per file are collected, not fatal.
pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|path| {
let inputs: Vec<BatchInput> = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect();
process_tree(&inputs, output, format, save, suffix, f)
}
/// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder
/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide.
pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| {
let ext =
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
(path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path))))
(path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path))))
});
process_outputs(outputs, save, f)
}
@@ -1000,8 +1108,8 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
}
let inputs = batch_inputs(p, cmd)?;
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
// Steps that call another action play it from the caller's actions; the playback stack comes
// along so an action that batches itself is caught as a recursive call.
@@ -1032,23 +1140,23 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
};
let r = match names {
Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run),
None => process_files(&inputs, &output, &format, save, "", &run),
Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
None => process_tree(&inputs, &output, &format, save, "", &run),
};
Ok(r)
}
fn image_processor(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.scripts.imageProcessor";
let inputs = batch_inputs(p, cmd)?;
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("jpg").to_string();
let fit = match (size_param(p, "width"), size_param(p, "height")) {
(None, None) => None,
(w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})),
};
let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false);
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| {
let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| {
if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) {
scratch.execute("image.mode.rgb", json!({}))?;
}
@@ -1549,7 +1657,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
batch
),
@@ -1558,7 +1666,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Processor…",
&["File", "Scripts"],
None,
r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
image_processor
),
@@ -1616,3 +1724,41 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
#[path = "file_cmds/tests.rs"]
mod tests;
#[cfg(test)]
mod aces_exr_tests {
use super::*;
/// File Handling › "Open color-less EXR as ACEScg": off, an EXR opens on io's linear-sRGB
/// default; on, it opens as ACEScg — the working space ACES render pipelines write.
#[test]
fn exr_aces_cg_preference_retags_the_opened_document() {
let image = photocraft_codecs::Image::from_f32(
4,
4,
photocraft_codecs::ChannelLayout::Rgb,
&[
0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5,
0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0,
],
)
.unwrap();
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap();
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
// Off (the default): io's linear-sRGB tagging stays.
let mut s = Session::new();
open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(linear.as_slice()));
// On: the same file opens as ACEScg.
let mut s = Session::new();
s.prefs.edit(|p| p.file_handling.exr_aces_cg = true);
open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice()));
}
}
+6 -5
View File
@@ -1,7 +1,7 @@
//! Batch output names are planned before opening or writing files. Names substituted from an
//! input are literal: braces in a source filename must never be interpreted as template tokens.
use super::{EngineError, Result, Value, join, stem};
use super::{BatchInput, EngineError, Result, Value, join, stem};
fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() }
@@ -51,7 +51,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
/// An absent or empty template preserves the old filename path exactly. An explicit template
/// generates one component per input; the export format supplies the extension separately.
pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
let start = match params.get("startingSerial") {
None => 1,
Some(n) => {
@@ -77,7 +77,7 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value
return Err(bad("naming: the current UTC date is unavailable"));
}
let mut outputs = Vec::with_capacity(inputs.len());
for (index, path) in inputs.iter().enumerate() {
for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() {
let serial = if has_serial {
u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))?
} else {
@@ -93,14 +93,15 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
}
outputs.push(join(output, &name));
// From subfolders ("Include All Subfolders"), each result keeps its subfolder.
outputs.push(join(&join(output, rel_dir), &name));
}
// Renaming within the source folder could replace a later input before it is read. Resolve
// existing paths so aliases such as `source/./001.png` cannot bypass the check. The default
// (no template) path keeps its existing in-place behavior.
#[cfg(not(target_arch = "wasm32"))]
{
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect();
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect();
for output in &outputs {
if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) {
return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template")));
+104
View File
@@ -1044,6 +1044,110 @@ fn reveal_in_finder_reveals_the_documents_saved_path() {
assert_eq!(r["program"], json!(program));
assert_eq!(r["args"], json!(args));
}
/// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in
/// subfolders too, each result keeping its subfolder under the output folder (so same-named files
/// don't collide), and skip the output folder when it sits inside the input folder.
#[test]
fn batch_include_all_subfolders() {
let dir = tmp("batch-subfolders");
let input = join(&dir, "in");
for sub in ["sub/deeper", "z"] {
std::fs::create_dir_all(join(&input, sub)).unwrap();
}
png(&input, "a.png", 8, 8, "#ff0000");
png(&join(&input, "sub"), "b.png", 8, 8, "#00ff00");
png(&join(&input, "sub/deeper"), "a.png", 8, 8, "#0000ff");
png(&join(&input, "z"), "a.png", 8, 8, "#ffffff");
let mut s = Session::new();
// Default: the top level only, as before.
let out = join(&input, "batch");
let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}");
// With subfolders: every image, in folder order, results kept apart by subfolder; the output
// folder (inside the input folder, holding last run's a.png) is not an input.
let r = s
.execute(
"file.automate.batch",
json!({"steps": [["image.adjustments.invert", {}]], "input": input, "includeSubfolders": true, "output": out, "format": "png"}),
)
.unwrap();
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let o = out.replace('\\', "/");
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]);
let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document;
let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0];
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}");
std::fs::remove_dir_all(&out).unwrap();
// A naming template applies within each kept subfolder (output outside the input folder).
let named = join(&dir, "named");
let r = s
.execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"}))
.unwrap();
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let n = named.replace('\\', "/");
assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]);
// Image Processor shares the option (with its results out of the input folder).
let out = join(&dir, "jpg");
let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (4, 0), "{r}");
assert!(std::path::Path::new(&join(&out, "sub/deeper/a.jpg")).is_file());
// A wrong type is a parameter error, for both.
for bad in [json!("yes"), json!(1), json!([true])] {
for cmd in ["file.automate.batch", "file.scripts.imageProcessor"] {
let e = s.execute(cmd, json!({"steps": [], "input": input, "includeSubfolders": bad, "output": out})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{cmd} {bad}: {e}");
}
}
// A missing input folder is an error, not a panic.
assert!(s.execute("file.automate.batch", json!({"steps": [], "input": join(&dir, "nope"), "includeSubfolders": true, "output": out})).is_err());
}
/// The subfolder walk stops at `MAX_SUBFOLDER_DEPTH` levels.
#[test]
fn batch_subfolder_walk_is_depth_limited() {
let dir = tmp("batch-subfolder-depth");
let mut deep = dir.clone();
for level in 1..=MAX_SUBFOLDER_DEPTH + 1 {
deep = join(&deep, "d");
std::fs::create_dir_all(&deep).unwrap();
if level >= MAX_SUBFOLDER_DEPTH {
std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap();
}
}
let found = list_images_tree(&dir, "", "test").unwrap();
let names: Vec<_> = found.iter().map(|i| file_name(&i.path)).collect();
assert_eq!(names, vec![format!("l{MAX_SUBFOLDER_DEPTH}.png")], "{found:?}");
assert_eq!(found[0].rel_dir.split('/').count(), MAX_SUBFOLDER_DEPTH);
}
/// A symlink back up the tree is walked once, not forever. Skipped where the platform doesn't
/// let the test create directory symlinks (Windows without developer mode).
#[test]
fn batch_subfolder_walk_survives_symlink_loops() {
let dir = tmp("batch-subfolder-loop");
let sub = join(&dir, "sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(join(&dir, "a.png"), b"x").unwrap();
std::fs::write(join(&sub, "b.png"), b"x").unwrap();
let link = std::path::Path::new(&sub).join("loop");
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&dir, &link);
#[cfg(windows)]
let made = std::os::windows::fs::symlink_dir(&dir, &link);
#[cfg(not(any(unix, windows)))]
let made: std::io::Result<()> = Err(std::io::Error::other("no symlinks"));
if made.is_err() {
eprintln!("skipped: cannot create a directory symlink here");
return;
}
let found = list_images_tree(&dir, "", "test").unwrap();
let names: Vec<_> = found.iter().map(|i| (i.rel_dir.as_str(), file_name(&i.path))).collect();
assert_eq!(names, vec![("", "a.png".to_string()), ("sub", "b.png".to_string())]);
}
#[test]
fn batch_naming_validates_the_whole_plan_before_writing() {
let dir = tmp("naming-invalid");
+34 -13
View File
@@ -235,24 +235,47 @@ pub(crate) fn blend_color_mask(
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
/// RGBA over the sampling window. Only the selected pixels are used.
fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result<Source> {
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
let hole_bounds = sel.content_bounds().intersect(&canvas);
if hole_bounds.is_empty() {
return Err(EngineError::Other("the selection is outside the canvas".into()));
}
let window = sampling_window(hole_bounds, canvas);
let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0;
let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?;
let fmt = surf.format();
let n = fmt.channels();
let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect();
Ok(Source::Pixels(window, px))
}
/// The pixels `is_hole` picks inside `hole_bounds` filled from their surroundings
/// (`photocraft_algo::content_aware`): Photoshop's sampling window around `hole_bounds`, sampling
/// only the non-hole pixels `usable` allows. Returns the window and its pixels in the surface's
/// own channels (the hole filled, the rest as read). Shared by Edit › Fill › Content-Aware and the
/// Crop tool's Content-Aware fill (#3823).
pub(crate) fn content_aware_native(
surf: &Surface,
hole_bounds: Rect,
canvas: Rect,
color_adaptation: bool,
seed: u64,
is_hole: impl Fn(i32, i32) -> bool,
usable: impl Fn(i32, i32) -> bool,
) -> Result<(Rect, Vec<f32>)> {
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
let window = sampling_window(hole_bounds, canvas);
let n = surf.format().channels();
let (w, h) = window_size(window)?;
let img = surf.read_region(window);
let mut hole = vec![false; w * h];
for (i, hv) in hole.iter_mut().enumerate() {
*hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0;
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?;
let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?;
let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?;
for (i, (hv, sv)) in hole.iter_mut().zip(source.iter_mut()).enumerate() {
let (x, y) = (window.x0 + (i % w) as i32, window.y0 + (i / w) as i32);
*hv = is_hole(x, y);
*sv = !*hv && usable(x, y);
}
if hole.iter().all(|h| *h) {
return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into()));
if !source.iter().any(|s| *s) {
return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into()));
}
let source = vec![true; w * h];
let opts = FillOptions {
color_adaptation: color_level(if color_adaptation { "default" } else { "none" }),
rotations: rotation_level("none"),
@@ -260,12 +283,10 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation:
mirror: false,
seed,
};
if img.len() != w * h * n {
if img.len() != len * n {
return Err(EngineError::Other("could not read the layer pixels".into()));
}
let filled = fill(w, h, n, &img, &hole, &source, &opts);
let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect();
Ok(Source::Pixels(window, px))
Ok((window, fill(w, h, n, &img, &hole, &source, &opts)))
}
/// Photoshop's Content-Aware sampling window around the selection bounds `hole`
+136 -8
View File
@@ -1,4 +1,4 @@
//! Validate new core-filter invocations, without changing how old smart-filter records decode.
//! Validate new filter invocations, without changing how old smart-filter records decode.
use serde_json::Value;
@@ -33,7 +33,7 @@ impl Rule {
}
}
// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in
// The bounds and choices are the filters' public dialog-unit contract. Defaults remain in
// params_for; gallery filters are checked in gallery_cmds.
fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
use Rule::{Bool, Choice, Number, Unsigned};
@@ -48,6 +48,20 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
("centerX", Number(0.0, 1.0)),
("centerY", Number(0.0, 1.0)),
],
"filter.blur.smartBlur" => &[
("radius", Number(0.1, 100.0)),
("threshold", Number(0.1, 100.0)),
("quality", Choice("high|medium|low")),
("mode", Choice("normal|edgeOnly|overlayEdge")),
],
"filter.blur.shapeBlur" => &[("radius", Number(5.0, 1000.0)), ("shape", Choice("circle|ring|square|diamond|triangle|hexagon|star|heart|cross"))],
"filter.noise.reduceNoise" => &[
("strength", Number(0.0, 10.0)),
("preserveDetails", Number(0.0, 100.0)),
("reduceColorNoise", Number(0.0, 100.0)),
("sharpenDetails", Number(0.0, 100.0)),
("removeJpegArtifact", Bool),
],
"filter.blur.surfaceBlur" => &[("radius", Number(1.0, 100.0)), ("threshold", Number(2.0, 255.0))],
"filter.sharpen.unsharpMask" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 1000.0)), ("threshold", Number(0.0, 255.0))],
"filter.sharpen.smartSharpen" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 64.0)), ("reduceNoise", Number(0.0, 100.0))],
@@ -92,8 +106,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
})
}
// Extended filters: only their documented choices are checked for now, since `prepare` injects
// extra keys (colours, map documents) and their numeric ranges clamp in params_for.
// Remaining extended filters: only their documented choices are checked for now, since some
// accept extra keys (colours, map documents) and their numeric ranges clamp in params_for.
fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> {
Some(match id {
"filter.pixelate.mezzotint" => {
@@ -109,11 +123,9 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> {
"filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")],
"filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")],
"filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")],
"filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")],
"filter.blur.lensBlur" => {
&[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")]
}
"filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")],
"filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")],
"filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")],
_ => return None,
@@ -324,9 +336,7 @@ mod tests {
"filter.distort.zigZag",
"filter.render.lensFlare",
"filter.render.lightingEffects",
"filter.blur.smartBlur",
"filter.blur.lensBlur",
"filter.blur.shapeBlur",
"filter.other.hsbHsl",
"filter.video.deInterlace",
];
@@ -348,6 +358,124 @@ mod tests {
s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap();
}
const STRICT_EXTENDED: [&str; 3] = ["filter.blur.smartBlur", "filter.blur.shapeBlur", "filter.noise.reduceNoise"];
#[test]
fn strict_extended_filters_reject_bad_fields_without_editing() {
let numbers = [
("filter.blur.smartBlur", "radius", 0.1, 100.0),
("filter.blur.smartBlur", "threshold", 0.1, 100.0),
("filter.blur.shapeBlur", "radius", 5.0, 1000.0),
("filter.noise.reduceNoise", "strength", 0.0, 10.0),
("filter.noise.reduceNoise", "preserveDetails", 0.0, 100.0),
("filter.noise.reduceNoise", "reduceColorNoise", 0.0, 100.0),
("filter.noise.reduceNoise", "sharpenDetails", 0.0, 100.0),
];
let specs = crate::filters_ext::specs();
for depth in [8, 16, 32] {
let mut s = session(depth);
for (id, key, min, max) in numbers {
let doc = specs.iter().find(|spec| spec.id == id).unwrap().params;
assert!(doc.contains(&format!("\"{key}\":{min}..{max}=")), "{id}.{key}");
for value in [json!("3"), json!(true), Value::Null, json!([]), json!({}), json!(min - 0.01), json!(max + 0.01), json!(1e300)] {
rejected(&mut s, id, key, value);
}
// Validate large endpoints without allocating large blur kernels.
for value in [min, max] {
validate_params(id, &json!({key:value})).unwrap();
}
}
for id in STRICT_EXTENDED {
for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] {
rejected(&mut s, id, key, value);
}
for value in [json!([]), json!(false), json!(3), json!("params")] {
assert!(matches!(validate_params(id, &value), Err(EngineError::BadParams { .. })));
}
}
for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] {
rejected(&mut s, "filter.noise.reduceNoise", "removeJpegArtifact", value);
}
}
for value in [false, true] {
validate_params("filter.noise.reduceNoise", &json!({"removeJpegArtifact":value})).unwrap();
}
for (id, key, allowed) in [
("filter.blur.smartBlur", "quality", "high|medium|low"),
("filter.blur.smartBlur", "mode", "normal|edgeOnly|overlayEdge"),
("filter.blur.shapeBlur", "shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross"),
] {
let doc = specs.iter().find(|spec| spec.id == id).unwrap().params;
assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}");
let mut s = session(8);
for value in [json!("typo"), json!(false), json!(1), Value::Null] {
rejected(&mut s, id, key, value);
}
for value in allowed.split('|') {
validate_params(id, &json!({key:value})).unwrap();
}
}
}
#[test]
fn strict_extended_filters_validate_before_floating_selection_commit_and_jobs() {
let mut s = session(16);
s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":4})).unwrap();
s.execute("select.float", json!({"dx":8})).unwrap();
for id in STRICT_EXTENDED {
rejected(&mut s, id, "radus", json!(3));
let doc = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.entries();
let journal = s.journal.clone();
let params = json!({"radus":3});
assert!(matches!(s.start(id, params.clone()), Err(EngineError::BadParams { .. })));
let spec = crate::commands::find(id).unwrap();
assert!(matches!((spec.run)(&mut s, &params), Err(EngineError::BadParams { .. })));
assert!(crate::float_cmds::floating(s.active().unwrap()).is_some());
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc));
assert_eq!(s.active().unwrap().history.entries(), history);
assert_eq!(s.journal, journal);
assert!(s.jobs().is_empty());
}
}
#[test]
fn strict_extended_filters_keep_defaults_targets_history_and_last_filter() {
for depth in [8, 16, 32] {
for id in STRICT_EXTENDED {
let mut s = session(depth);
let layer = s.active().unwrap().active_layer.unwrap();
for params in [Value::Null, json!({}), json!({"layer":layer.0,"target":"pixels","coalesce":"extended"})] {
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(id, params).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = s.active().unwrap().doc.clone();
s.undo();
assert_eq!(*s.active().unwrap().doc, *before);
s.redo();
assert_eq!(*s.active().unwrap().doc, *after);
}
s.execute("filter.lastFilter", json!({})).unwrap();
// The session injects this target when editing an alpha channel.
s.execute("channel.new", json!({"fill":"white"})).unwrap();
s.execute(id, json!({})).unwrap();
}
}
}
#[test]
fn stored_extended_filter_records_still_decode_tolerantly() {
use photocraft_algo::{BlurQuality, BlurShape, FilterParams, SmartBlurMode};
let smart =
crate::filters::params_for("filter.blur.smartBlur", &json!({"radius":-2,"threshold":900,"quality":"old","mode":false,"extra":true})).unwrap();
assert!(matches!(smart, FilterParams::SmartBlur { radius: 0.1, threshold: 100.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal }));
let shape = crate::filters::params_for("filter.blur.shapeBlur", &json!({"radius":2000,"shape":"old"})).unwrap();
assert!(matches!(shape, FilterParams::ShapeBlur { radius: 1000.0, shape: BlurShape::Circle }));
let noise = crate::filters::params_for("filter.noise.reduceNoise", &json!({"strength":-1,"preserveDetails":101,"removeJpegArtifact":"yes"})).unwrap();
assert!(matches!(noise, FilterParams::ReduceNoise { strength: 0.0, preserve_details: 100.0, remove_jpeg_artifact: false, .. }));
}
#[test]
fn invalid_filters_leave_floating_selection_and_background_jobs_alone() {
let mut s = session(16);
+29 -6
View File
@@ -3,7 +3,7 @@
//! primitives: group the selected layers, then clip the group with a rectangular layer mask the
//! size of their bounds — the same visual result Photoshop's Frame tool produces for this command.
//! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on
//! the canvas, empty or holding the pixel layer it was drawn over. Headless and scriptable like
//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like
//! every other command.
use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat};
@@ -107,8 +107,9 @@ fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask {
}
/// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one
/// history step. With `layer` (a pixel layer, not the Background), that layer becomes the frame's
/// content, as when a frame is drawn over a selected pixel layer in Photoshop; else it is empty.
/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background),
/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in
/// Photoshop; else it is empty.
fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.new.frame";
let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg };
@@ -137,8 +138,9 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
let gid = match content {
Some(id) => {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_));
if !framable || doc.is_background(id) {
return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into()));
}
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?;
@@ -163,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "New Frame",
menu: &[],
shortcut: None,
params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel layer `layer` if given"#,
params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
journal: true,
run: new_frame,
@@ -274,6 +276,27 @@ mod tests {
}
}
/// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer.
#[test]
fn new_frame_takes_a_type_layer() {
let (mut s, a, _) = session();
let tid = s
.edit("setup", |doc, _| {
let cache = doc.layer(a).and_then(|l| l.surface()).cloned();
let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() }));
let id = t.id;
doc.layers.push(t);
Ok(id)
})
.unwrap();
let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap();
let gid = LayerId(r["layer"].as_u64().unwrap());
let g = s.active().unwrap().doc.layer(gid).unwrap();
assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer");
assert!(s.undo());
assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back");
}
#[test]
fn needs_a_selected_layer() {
let mut s = Session::new();
+43 -10
View File
@@ -71,11 +71,12 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer)
}
}
/// Assign new layer ids to a copied document and update every saved layer-comp reference.
/// Assign new layer ids to a copied document and update every saved layer reference.
///
/// A document copy needs fresh identities, but its comps must still target the copied
/// layers. Deliberately leave references to already-deleted layers untouched so their
/// missing-layer warnings remain meaningful. The same applies to Last Document State.
/// A document copy needs fresh identities, but its comps and variable definitions must
/// still target the copied layers. Deliberately leave references to already-deleted
/// layers untouched so their missing-layer warnings remain meaningful. The same applies
/// to Last Document State.
pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
let mut ids = std::collections::HashMap::new();
for_each_layer(&mut doc.layers, &mut |layer| {
@@ -97,6 +98,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
if let Some(last) = &mut doc.last_document_state {
remap(last);
}
for def in &mut doc.variables.defs {
if let Some(&new_id) = ids.get(&def.layer) {
def.layer = new_id;
}
}
}
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
@@ -428,6 +434,12 @@ const MAX_CROP_SIDE: i32 = 300_000;
/// Image → Crop (to the selection bounds).
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
// The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823).
let content_aware = match p.get("contentAware") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))),
};
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
// the numbers passed; reject them (see `commands::int_i32`).
let explicit = match (
@@ -459,7 +471,7 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(a) = crop_angle(p)? {
let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?;
return rotated_crop(s, rect, a, delete, &target);
return rotated_crop(s, rect, a, delete, content_aware, &target);
}
// An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped.
let r = explicit
@@ -470,8 +482,12 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
}
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)));
let extension = s.tools.background;
s.edit("Crop", |doc, _| {
s.edit("Crop", |doc, active| {
let original = doc.size;
crop_doc(doc, "image.crop", r, delete, extension)?;
if content_aware {
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
}
doc.selection = None;
target.apply(doc);
Ok(())
@@ -579,8 +595,9 @@ fn crop_angle(p: &Value) -> Result<Option<f64>> {
/// Arbitrary: the canvas grows to fit, the Background fills what the rotation uncovers with the
/// background colour, other layers keep transparency there and every pixel of theirs), which makes
/// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in
/// one undo step.
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result<Value> {
/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the
/// rotated image) are then filled from their surroundings, in the same step (#3823).
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result<Value> {
const CMD: &str = "image.crop";
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
let (w, h) = (f64::from(r.width()), f64::from(r.height()));
@@ -601,13 +618,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target:
let end = |o: i32, len: u32| i32::try_from(len).ok().and_then(|l| o.checked_add(l));
let (Some(x1), Some(y1)) = (end(x, r.width()), end(y, r.height())) else { return Err(far()) };
let target = Rect::new(x, y, x1, y1);
// The cropped canvas back to the original document: undo the crop's offset, then the rotation
// (x = c·(x' − nx) + s·(y' − ny) + cx, y = −s·(x' − nx) + c·(y' − ny) + cy).
let (ox, oy) = (f64::from(target.x0) - f64::from(new.width) / 2.0, f64::from(target.y0) - f64::from(new.height) / 2.0);
let (ocx, ocy) = (f64::from(size.width) / 2.0, f64::from(size.height) / 2.0);
let coverage = crate::crop_fill::Coverage {
map: [cs, sn, -sn, cs, cs * ox + sn * oy + ocx, -sn * ox + cs * oy + ocy],
width: f64::from(size.width),
height: f64::from(size.height),
rotated: true,
};
crate::analysis_cmds::compound(s, "Crop", |s| {
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0)));
let extension = s.tools.background;
s.edit("Crop", |doc, _| {
s.edit("Crop", |doc, active| {
crop_doc(doc, CMD, target, delete, extension)?;
if content_aware {
crate::crop_fill::fill_uncovered(doc, *active, &coverage)?;
}
doc.selection = None;
crop_target.apply(doc);
Ok(())
@@ -711,6 +741,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
doc.layer_comps.clear();
doc.last_applied_comp = None;
doc.last_document_state = None;
// Likewise, variables drove template layers that no longer exist: drop the whole
// binding set (defs, data sets and the active index) on the flattened copy.
doc.variables = photocraft_doc::Variables::default();
}
reidentify_copied_layers(&mut doc);
let name = doc.name.clone();
@@ -747,7 +780,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.crop",
"Crop",
["Image"],
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi)"##,
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
has_doc,
crop
),
+27 -8
View File
@@ -1,6 +1,8 @@
//! JSON views of engine state for automation, tests and debugging.
use photocraft_doc::{Layer, LayerContent, SmartSource};
use std::collections::HashMap;
use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource};
use serde_json::{Value, json};
use crate::{DocState, Session};
@@ -25,6 +27,7 @@ pub fn session(s: &Session) -> Value {
pub fn document(d: &DocState) -> Value {
let doc = &d.doc;
let selected = d.selected_layers();
let instances = smart_instances(&doc.layers);
json!({
"name": doc.name,
"path": &d.path,
@@ -38,7 +41,7 @@ pub fn document(d: &DocState) -> Value {
"isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::<Vec<_>>(),
"hasSelection": doc.selection.is_some(),
"selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }),
"layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::<Vec<_>>(),
"layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::<Vec<_>>(),
"history": d.history.entries(),
"snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::<Vec<_>>(),
"canUndo": d.history.can_undo(),
@@ -61,14 +64,27 @@ pub fn document(d: &DocState) -> Value {
})
}
/// Layer tree top-to-bottom (display order).
/// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`.
pub fn layer(l: &Layer) -> Value {
layer_sel(l, &[], None)
layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None)
}
/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the
/// document's `meta`, a smart object whose file is embedded in the PSD says so.
fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value {
/// How many smart objects in `layers`, groups included, share each contents identity.
fn smart_instances(layers: &[Layer]) -> HashMap<SmartContentsId, usize> {
let mut counts = HashMap::new();
let mut pending: Vec<&Layer> = layers.iter().collect();
while let Some(l) = pending.pop() {
match &l.content {
LayerContent::Smart(sm) => *counts.entry(sm.contents_id).or_default() += 1,
LayerContent::Group(g) => pending.extend(&g.children),
_ => {}
}
}
counts
}
/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection).
fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap<SmartContentsId, usize>, meta: Option<&photocraft_doc::Metadata>) -> Value {
let bounds = l.surface().map(|s| {
let r = s.content_bounds();
[r.x0, r.y0, r.width() as i32, r.height() as i32]
@@ -92,7 +108,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot
});
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect());
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
@@ -122,6 +138,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot
None => json!({"kind": "linked", "path": path}),
},
};
// Copies of the same contents change together (Edit and Replace Contents), and
// `smartSource` can't tell them from independent copies of one file (#4075).
v["smartContents"] = json!({"id": sm.contents_id.0, "instances": instances.get(&sm.contents_id).copied().unwrap_or(1)});
}
LayerContent::Text(t) => {
v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt});
+26 -2
View File
@@ -382,6 +382,24 @@ pub fn edit_job<R: Send + 'static>(
)
}
/// Scan every layer surface's content bounds into the shared tile bounds cache, so the first
/// draw of a document opened in the background doesn't do it on the UI thread: GPU planning
/// asks each layer for its bounds, a multi-second scan on a multi-gigabyte PSB (#4024).
/// `check` runs between layers (cancellation).
pub fn warm_layer_bounds(doc: &Document, mut check: impl FnMut() -> Result<()>) -> Result<()> {
let mut pending: Vec<&photocraft_doc::Layer> = doc.layers.iter().collect();
while let Some(layer) = pending.pop() {
check()?;
if let Some(children) = layer.children() {
pending.extend(children);
}
if let Some(surface) = layer.surface() {
photocraft_compose::bounds::content_bounds(surface);
}
}
Ok(())
}
/// Where a background open reads its file from.
#[derive(Clone, Debug)]
pub enum OpenSource {
@@ -434,11 +452,15 @@ impl Session {
};
ctx.check()?;
ctx.progress(0.02, "Decoding");
ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl))
let imported = ctx
.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl))
.map_err(|e| match e {
photocraft_io::IoError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
})
})?;
ctx.progress(0.95, "Preparing layers");
warm_layer_bounds(&imported.document, || ctx.check())?;
Ok(imported)
},
move |s, r: photocraft_io::ImportResult| {
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
@@ -724,6 +746,8 @@ impl Session {
self.journal.truncate(from);
}
if !crate::brush_cmds::coalesce_journal(self, id, &params) {
let params =
if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params };
self.journal.push((id.to_string(), params));
}
}
+26
View File
@@ -490,6 +490,32 @@ fn an_open_that_asks_about_its_profile_holds_the_document_back() {
assert_eq!(s.documents().len(), 1);
}
#[test]
fn warm_layer_bounds_visits_every_layer_and_can_be_cancelled() {
let mut s = session(40, 30);
s.execute("layer.groupLayers", json!({})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let doc = s.active().unwrap().doc.clone();
fn count(layers: &[photocraft_doc::Layer]) -> usize {
layers.iter().map(|l| 1 + l.children().map_or(0, count)).sum()
}
assert!(doc.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "a group with a layer in it");
let mut checks = 0;
warm_layer_bounds(&doc, || {
checks += 1;
Ok(())
})
.unwrap();
assert_eq!(checks, count(&doc.layers), "one check per layer, nested ones included");
let mut checks = 0;
let r = warm_layer_bounds(&doc, || {
checks += 1;
if checks > 1 { Err(EngineError::Cancelled) } else { Ok(()) }
});
assert!(matches!(r, Err(EngineError::Cancelled)));
assert_eq!(checks, 2, "stops at the first failed check");
}
#[test]
fn wait_job_blocks_until_applied() {
let mut s = session(400, 300);
+29 -5
View File
@@ -9,10 +9,11 @@
//! Where one label sits in several menus (Levels… under Image › Adjustments and Layer › New
//! Adjustment Layer; All under Select, Edit › Purge, View › Show), the command whose default is
//! one of the imported keys wins, then the one that has a default at all, then the first
//! candidate (the app lists them in Photoshop's menu order). Ours are one per command, so a
//! row's first shortcut is the one kept, unless one of its keys is already the command's
//! default (Zoom In lists `Cmd++` before `Cmd+=`). Tool keys are not imported: the tool
//! letters are fixed here.
//! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the
//! command's main key, unless one of its keys is already the command's default (Zoom In lists
//! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is
//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not
//! imported: the tool letters are fixed here.
use std::collections::{BTreeMap, HashMap};
@@ -81,8 +82,12 @@ pub struct Plan {
pub unknown: Vec<String>,
/// Rows whose shortcut could not be read (`unknown` does not list them).
pub unreadable: Vec<String>,
/// Second and later shortcuts of a row, which have no place here.
/// Second and later shortcuts of the matched rows (counted; kept in `alternate_keys`).
pub alternates: usize,
/// Command id → the matched row's other keys (normalised, without the main key in `set`),
/// for every matched row: a set lists each command's whole binding, so an empty list
/// means the command has no alternates in it.
pub alternate_keys: BTreeMap<String, Vec<String>>,
}
/// One command a row may match: its id, its label and its default shortcut.
@@ -141,6 +146,13 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
// (Zoom In is "Cmd++" then "Cmd+=" in Photoshop; ours is Cmd+=).
let default = default.as_deref().and_then(normalize_shortcut);
let sc = default.filter(|d| imported.contains(d)).unwrap_or(sc);
let mut rest: Vec<String> = Vec::new();
for k in imported {
if k != sc && !rest.contains(&k) && rest.len() < crate::prefs::MAX_ALTERNATES {
rest.push(k);
}
}
plan.alternate_keys.insert(id.clone(), rest);
plan.set.insert(id.clone(), sc);
}
}
@@ -179,6 +191,18 @@ mod tests {
assert_eq!(p.unknown, ["Hide Photoshop"]);
assert_eq!(p.unreadable, ["Odd"]);
assert_eq!(p.alternates, 1);
// #3665: the row's second key is kept as the command's alternate, not dropped.
assert_eq!(p.alternate_keys.get("edit.undo"), Some(&vec!["F1".to_string()]));
assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new()));
}
#[test]
fn a_row_with_the_default_and_a_function_key_keeps_both() {
let xml =
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F3</shortcut><shortcut>Cmd+C</shortcut></command></photoshop-keyboard-shortcuts>";
let p = plan([c("edit.copy", "Copy", Some("Cmd+C"))], &parse(xml).unwrap());
assert_eq!(p.set.get("edit.copy").map(String::as_str), Some("Cmd+C"));
assert_eq!(p.alternate_keys.get("edit.copy"), Some(&vec!["F3".to_string()]));
}
#[test]
+163
View File
@@ -0,0 +1,163 @@
//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has
//! it, buried among history, channels and much more; an agent looking for the layers' ids looks
//! for a `layer.*` command first.
use photocraft_doc::{Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{DocState, EngineError, Result, Session};
const ID: &str = "layer.list";
/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be
/// deep, and the tree is built recursively).
const MAX_DEPTH: usize = 256;
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// One layer's row: what an agent needs to pick a layer to work on.
fn row(l: &Layer, selected: &[LayerId], active: Option<LayerId>) -> Value {
let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]);
json!({
"id": l.id.0,
"name": l.name,
"kind": l.content.kind_name(),
"visible": l.visible,
"bounds": bounds,
"selected": selected.contains(&l.id),
"active": active == Some(l.id),
})
}
/// The layers top-down (the Layers panel's order), groups with their `children`.
fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec<Value> {
if depth >= MAX_DEPTH {
return Vec::new();
}
layers
.iter()
.rev()
.map(|l| {
let mut v = row(l, selected, d.active_layer);
if let LayerContent::Group(g) = &l.content {
v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1));
}
v
})
.collect()
}
/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id.
fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option<LayerId>, depth: usize, out: &mut Vec<Value>) {
if depth >= MAX_DEPTH {
return;
}
for l in layers.iter().rev() {
let mut v = row(l, selected, d.active_layer);
v["depth"] = json!(depth);
v["parent"] = json!(parent.map(|p| p.0));
out.push(v);
if let LayerContent::Group(g) = &l.content {
flat(&g.children, d, selected, Some(l.id), depth + 1, out);
}
}
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() };
let d = match p.get("document") {
None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?,
Some(v) => {
let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?;
s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))?
}
};
let as_flat = match p.get("flat") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?,
};
let selected = d.selected_layers();
let layers = if as_flat {
let mut out = Vec::new();
flat(&d.doc.layers, d, &selected, None, 0, &mut out);
out
} else {
tree(&d.doc.layers, d, &selected, 0)
};
Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "List Layers",
menu: &[],
shortcut: None,
params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##,
enabled: has_doc,
run,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s
}
#[test]
fn lists_ids_names_kinds_and_nesting_top_down() {
let mut s = session_with_doc();
let bg = s.active().unwrap().active_layer.unwrap().0;
let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().doc.layers.last().unwrap().id.0;
s.execute("layer.select", json!({"layer": inner})).unwrap();
s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap();
let r = s.execute(ID, json!({})).unwrap();
let layers = r["layers"].as_array().unwrap();
assert_eq!(layers.len(), 2, "{r}");
assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}");
assert_eq!(layers[0]["children"][0]["id"], inner, "{r}");
assert_eq!(layers[0]["children"][0]["active"], true, "{r}");
assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}");
assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children");
assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false)));
assert!(layers[1].get("children").is_none());
assert_eq!(r["activeLayer"], inner);
// Flat: one row per layer, with its depth and parent group.
let r = s.execute(ID, json!({"flat": true})).unwrap();
let rows: Vec<(u64, u64, Option<u64>)> =
r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect();
assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]);
}
#[test]
fn is_a_query_and_leaves_the_document_alone() {
let mut s = session_with_doc();
let revision = s.active().unwrap().revision;
let journal = s.journal.len();
s.execute(ID, json!({})).unwrap();
assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal));
}
#[test]
fn bad_params_are_errors_not_panics() {
let mut s = Session::new();
assert!(s.execute(ID, json!({})).is_err(), "no document");
let mut s = session_with_doc();
for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok());
}
}
+75 -2
View File
@@ -100,6 +100,48 @@ pub(crate) fn reselect(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerI
}
}
/// Capture a selection target while its name is still known. Later renames/deletions must not
/// change an earlier recorded selection. Direct, non-recording calls retain their numeric IDs.
pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value {
let Some(id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p };
let Some(st) = s.active() else { return p };
let Some(layer) = st.doc.layer(id) else { return p };
let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect();
let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p };
if let Some(fields) = p.as_object_mut() {
fields.remove("layer");
fields.insert("name".into(), json!(layer.name));
if matches.len() > 1 {
fields.insert("occurrence".into(), json!(occurrence));
}
}
p
}
fn selection_target(s: &Session, p: &Value) -> Result<LayerId> {
let Some(name) = p.get("name") else {
if p.get("occurrence").is_some() {
return Err(bad("layer.select", "`occurrence` requires `name`"));
}
return crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "needs `layer` or `name`"));
};
if p.get("layer").is_some() {
return Err(bad("layer.select", "use either `layer` or `name`, not both"));
}
let name = name.as_str().ok_or_else(|| bad("layer.select", "`name` must be a string"))?;
let occurrence = p
.get("occurrence")
.map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("layer.select", "`occurrence` must be a non-negative integer")))
.transpose()?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).collect();
if matches.len() > 1 && occurrence.is_none() {
return Err(bad("layer.select", format!("multiple layers named `{name}`; specify `occurrence`")));
}
let index = occurrence.unwrap_or(0);
matches.get(index).copied().ok_or_else(|| bad("layer.select", format!("no layer named `{name}` at occurrence {index}")))
}
/// Layer rows follow the Layers panel's visual order, not Document::walk's storage order.
/// Closed groups hide their children unless both endpoints were selected programmatically.
fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec<LayerId>) {
@@ -115,7 +157,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec<LayerId>
/// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`.
pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?;
let id = selection_target(s, p)?;
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace");
let st = s.active().ok_or(EngineError::NoDocument)?;
if st.doc.layer(id).is_none() {
@@ -1270,7 +1312,7 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut merged = Layer::raster(top.name.clone(), fmt);
merged.locks = top.locks;
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None);
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
let mid = merged.id;
// Hidden selected layers are discarded, as in Photoshop.
for id in &ids[..ids.len() - 1] {
@@ -2051,6 +2093,37 @@ mod tests {
assert!(!s.is_enabled("layer.distribute.leftEdges"));
}
#[test]
fn merge_keeps_pixels_outside_the_canvas() {
// #5067: the merged layer was cropped to the 100x80 canvas.
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10));
let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120));
select_all(&mut s, &[a, b]);
let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
let ml = doc(&s).layer(m).unwrap();
assert_eq!(layer_bounds(ml).unwrap(), Rect::new(-30, -20, 140, 120));
assert_eq!(ml.surface().unwrap().pixel(-10, -5)[3], 1.0);
assert_eq!(ml.surface().unwrap().pixel(130, 110)[3], 1.0);
}
#[test]
fn merge_down_and_merge_group_keep_pixels_outside_the_canvas() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10));
let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120));
s.execute("layer.mergeDown", json!({"layer": b.0})).unwrap();
assert_eq!(layer_bounds(doc(&s).layer(a).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120));
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10));
let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120));
select_all(&mut s, &[a, b]);
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.mergeGroup", json!({"layer": g.0})).unwrap();
assert_eq!(layer_bounds(doc(&s).layer(g).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120));
}
#[test]
fn merge_selected_into_top_most() {
for depth in [8, 16, 32] {
+62 -3
View File
@@ -98,7 +98,7 @@ pub fn effect_defaults(kind: &str) -> Value {
}
"patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}),
"bevelEmboss" => {
json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
}
"satin" => {
json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true})
@@ -895,8 +895,10 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result<Value> {
}
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
// The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye)
// survives the edit: the dialog models per-effect state, not the switch (#3648). A layer
// with no effects yet still has the default `enabled: true`, so its style shows.
l.effects.items = list.into_iter().map(|e| e.in_mode(mode)).collect();
l.effects.enabled = true;
Ok(())
})?;
Ok(json!({ "layer": id.0, "effects": count }))
@@ -915,7 +917,8 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
// Colours in the document's mode, as Image › Mode keeps them.
let fx = fx.in_mode(doc.mode);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects.enabled = true;
// Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh
// effects list already has the default `enabled: true`, so a first effect still shows.
match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) {
Some(slot) if !add => *slot = fx,
_ => l.effects.items.push(fx),
@@ -1197,6 +1200,62 @@ mod tests {
assert_eq!(effects(&s).len(), 1);
}
fn pixel(s: &mut Session) -> Vec<f32> {
serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap()
}
fn list_enabled(s: &Session) -> bool {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.enabled
}
/// #3648: the Layers panel's "Effects" eye (`layer.setEffectsVisible`) must survive a Layer
/// Style dialog OK: the dialog's whole-list replace re-creates the items but leaves the
/// list's master switch alone.
#[test]
fn replacing_the_list_keeps_a_disabled_effect_list_disabled() {
let mut s = session();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap();
let shown = pixel(&mut s);
s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap();
let hidden = pixel(&mut s);
assert_ne!(hidden, shown, "the eye really hides the style");
let r = s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "colorOverlay", "params": {"color": "#00ff00"}}]})).unwrap();
assert_eq!(r["effects"], 1, "the list itself is updated");
assert!(!list_enabled(&s), "the whole-list switch survives the edit");
assert!(effects(&s)[0].enabled(), "the effect's own state is updated");
assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing");
s.execute("layer.setEffectsVisible", json!({"visible": true})).unwrap();
assert_ne!(pixel(&mut s), hidden, "re-showing the list draws the edited effect");
}
/// #3648: adding or replacing one effect (`layer.layerStyle.<kind>`) also leaves the master
/// switch as the user left it.
#[test]
fn set_effect_keeps_a_disabled_effect_list_disabled() {
let mut s = session();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap();
s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap();
let hidden = pixel(&mut s);
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap();
assert!(!list_enabled(&s), "the whole-list switch survives the edit");
assert!(effects(&s)[0].enabled(), "the effect's own state is updated");
assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing");
}
/// A layer that never had effects keeps the default: its first effect shows.
#[test]
fn a_first_effect_shows_on_a_layer_that_never_had_effects() {
let mut s = session();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
let plain = pixel(&mut s);
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap();
assert!(list_enabled(&s), "a fresh effects list defaults to enabled");
assert_ne!(pixel(&mut s), plain, "the first effect renders");
}
/// #3102: an effect row dropped on the Delete button deletes only that effect, in one step.
#[test]
fn clear_one_effect_by_index() {
+3
View File
@@ -30,6 +30,7 @@ pub mod color_cmds;
pub mod color_to_alpha_cmds;
pub mod commands;
pub mod comps_cmds;
mod crop_fill;
pub mod cutout_cmds;
pub mod display_color;
pub mod distort_cmds;
@@ -37,6 +38,7 @@ pub mod document_preset_cmds;
pub mod edit_cmds;
pub mod edit_menu_cmds;
pub mod eraser_cmds;
pub mod export_preset_cmds;
pub mod exr_cmds;
pub mod extra_cmds;
pub mod file_cmds;
@@ -62,6 +64,7 @@ pub mod jobs;
pub mod kys;
pub mod layer_copy_cmds;
pub mod layer_label_cmds;
pub mod layer_list_cmds;
pub mod layer_mask_props_cmds;
pub mod layer_menu_cmds;
pub mod layer_multi_cmds;
+38 -2
View File
@@ -80,6 +80,9 @@ fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect::<Result<_>>()?;
let d = offset(CMD, p)?;
if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) {
return edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors_curvature(path, &anchors, d));
}
edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors(path, &anchors, d))
}
@@ -106,6 +109,9 @@ fn bend_segment(s: &mut Session, p: &Value) -> Result<Value> {
fn convert_point(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.convertPoint";
let k = knot(CMD, p)?;
if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) {
return edit_path(s, CMD, p, "Convert Point", |path| edit::toggle_curvature_point(path, k));
}
let out = point(CMD, p, "out")?;
edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out))
}
@@ -155,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
journal: true,
};
vec![
spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy] (anchors move with their handles; Direct Selection drag)"#.into(), move_anchors),
spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy],"curvature":bool=false (anchors move with their handles; Direct Selection drag. curvature = the Curvature Pen's drag: every smooth knot of the touched subpaths gets its handles recomputed so the curve flows through the anchors)"#.into(), move_anchors),
spec(
"path.moveHandle",
"Drag Direction Point",
@@ -171,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec(
"path.convertPoint",
"Convert Point",
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror)"#.into(),
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored)"#.into(),
convert_point,
),
spec(
@@ -226,6 +232,36 @@ mod tests {
assert_eq!(work(&s), before);
}
#[test]
fn a_curvature_anchor_drag_reflows_the_smooth_knots() {
let mut s = session();
let smooth = |a: [f64; 2]| json!({"anchor": a, "in": a, "out": a, "smooth": true});
let path = json!({"subpaths": [{"closed": true, "knots": [smooth([0.0, 50.0]), smooth([100.0, 0.0]), smooth([200.0, 50.0]), smooth([100.0, 100.0])]}]});
s.execute("path.set", json!({"path": path})).unwrap();
s.execute("path.moveAnchors", json!({"anchors": [[0, 1]], "move": [0, 60], "curvature": true})).unwrap();
let p = work(&s);
let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]];
let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 4], true);
for (k, w) in p.subpaths[0].knots.iter().zip(want) {
assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w);
}
assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points"));
assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 0], "curvature": true})).is_err());
}
#[test]
fn a_curvature_convert_toggles_the_point_and_reflows() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let before = work(&s);
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap();
let k = work(&s).subpaths[0].knots[1];
assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "{k:?}");
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap();
assert_eq!(work(&s), before);
assert!(s.execute("path.convertPoint", json!({"subpath": 0, "knot": 9, "curvature": true})).is_err());
}
#[test]
fn a_coalesced_drag_is_one_history_step() {
let mut s = session();
+79 -11
View File
@@ -28,6 +28,36 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat,
photocraft_compose::composite_to_surface(solo, area, format, background)
}
/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept
/// (merging layers must not lose them, #5067).
pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface {
let canvas = solo.bounds();
// The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`.
let reach = solo
.layers
.iter()
.filter(|layer| layer.visible)
.map(|layer| {
let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 };
let bounds = photocraft_compose::layer_bounds(layer, canvas);
if bounds.is_empty() { bounds } else { bounds.inflate(margin) }
})
.filter(|bounds| !bounds.is_empty())
.fold(canvas, |a, b| a.union(&b));
let area = solo
.layers
.iter()
.filter(|layer| layer.visible)
.map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas))
.filter(|bounds| !bounds.is_empty())
.reduce(|a, b| a.union(&b))
.unwrap_or(Rect::EMPTY);
if area.is_empty() {
return Surface::new(format);
}
photocraft_compose::composite_to_surface(solo, area, format, None)
}
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
@@ -92,23 +122,39 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface
fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec<f32> {
let fmt = s.format();
if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) {
return adjusted_channels(s, r, adj, selection);
return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch));
}
if fmt.mode == mode && mode == photocraft_color::ColorMode::Cmyk && adjust::cmy_adjustment(adj) {
return adjusted_cmy(s, r, adj, selection);
}
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj
&& fmt.mode == mode
&& mode == photocraft_color::ColorMode::Cmyk
{
let table = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
return adjusted_channels(s, r, selection, |ch| ch.iter_mut().for_each(|v| *v = adjust::ink_through(&table, *v)));
}
let n = fmt.channels();
let raw = s.read_region(r);
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
let orig = buf.clone();
// 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip);
// integer depths keep the unrounded, clipped curves.
let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample);
// integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and
// Threshold use the 16-bit rules an adjustment layer measures against Photoshop, so the
// command matches its live preview (an adjustment layer) and Photoshop.
let depth = match (fmt.sample, adj) {
(photocraft_color::SampleType::F32, _) => Some(fmt.sample),
(photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample),
_ => None,
};
adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode);
mix_through_selection(&fmt, r, &orig.px, &buf.px, selection)
}
/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels
/// (`adjust::stored_channels`): exact on the stored values, with no trip through RGB, and mixed
/// through the selection on those values (alpha untouched).
fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec<f32> {
/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels: `op`
/// changes one pixel's colour channels, exact on the stored values with no trip through RGB, and
/// the selection mixes those values (alpha untouched).
fn adjusted_channels(s: &Surface, r: Rect, selection: Option<&Surface>, op: impl Fn(&mut [f32])) -> Vec<f32> {
let fmt = s.format();
let (n, colors) = (fmt.channels(), fmt.mode.color_channels());
let mut raw = s.read_region(r);
@@ -122,13 +168,11 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&
if k <= 0.0 {
continue;
}
let p = to_rgba(&fmt, px);
let gray = photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]);
let Some(ch) = px.get_mut(..colors) else { continue };
let mut out = [0.0f32; 4];
let Some(out) = out.get_mut(..colors) else { continue };
out.copy_from_slice(ch);
adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), out, gray);
op(out);
for (v, o) in ch.iter_mut().zip(out.iter()) {
*v += (o - *v) * k;
}
@@ -137,6 +181,29 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&
raw
}
/// [`adjusted`] for Hue/Saturation or Color Balance on a CMYK layer (`adjust::apply_cmy`): on its
/// stored C, M and Y, black kept, the whole region at once; the selection mixes the stored inks.
fn adjusted_cmy(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec<f32> {
let fmt = s.format();
let n = fmt.channels();
let mut raw = s.read_region(r);
let mut inks: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| std::array::from_fn(|c| p.get(c).copied().unwrap_or(0.0))).collect();
adjust::apply_cmy(adj, &mut inks);
let w = (r.width() as usize).max(1);
let mut coverage = vec![1.0f32; w];
for (j, (row, adjusted)) in raw.chunks_mut(w * n).zip(inks.chunks(w)).enumerate() {
if let Some(sel) = selection {
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
}
for ((px, ink), &k) in row.chunks_exact_mut(n).zip(adjusted).zip(&coverage) {
for (v, a) in px.iter_mut().zip(ink) {
*v += (a - *v) * k;
}
}
}
raw
}
/// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's
/// coverage, encoded in `fmt`: what a destructive edit writes back through a selection.
pub(crate) fn mix_through_selection(
@@ -339,7 +406,8 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
// The pair is read (and a CMYK one separated again) through the document's own profile.
tmp.icc_profile = doc.icc_profile.clone();
tmp.layers = stack;
let s = composite_layers(&tmp, format, None);
// A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067).
let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) };
let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s));
merged.id = lower.id;
merged.blend = lower.blend;
+45
View File
@@ -349,3 +349,48 @@ fn invert_posterize_and_threshold_change_lab_and_cmyk_channels_as_stored() {
assert_levels(&adjusted_pixel(lab8, &f([128.0, 30.0, 220.0, 255.0]), &level, None), &[255.0, 128.0, 128.0, 255.0], "Lab threshold light");
assert_levels(&adjusted_pixel(lab16, &f([127.0, 30.0, 220.0, 255.0]), &level, None), &[0.0, 128.0, 128.0, 255.0], "Lab threshold dark");
}
/// Image › Adjustments › Brightness/Contrast on a CMYK layer curves each stored ink value
/// (1 - ink) exactly, modern and legacy, and a selection mixes the stored inks.
#[test]
fn brightness_contrast_curves_cmyk_inks_as_stored() {
let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
let ink = [0.2, 0.4, 0.6, 0.1, 1.0];
for legacy in [false, true] {
let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy };
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, legacy);
let got = adjusted_pixel(cmyk, &ink, &adj, None);
for c in 0..4 {
let want = adjust::ink_through(&table, ink[c]);
assert!((got[c] - want).abs() < 1e-4, "legacy {legacy} ink {c}: {} vs {want}", got[c]);
}
assert_eq!(got[4], 1.0);
let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5));
for c in 0..4 {
let want = ink[c] + (adjust::ink_through(&table, ink[c]) - ink[c]) * 128.0 / 255.0;
assert!((half[c] - want).abs() < 1e-3, "legacy {legacy} ink {c} through half a selection");
}
}
}
/// Image › Adjustments › Hue/Saturation and Color Balance on a CMYK layer work on its stored C, M
/// and Y as inverted RGB, keep its black ink exactly, and mix through a selection.
#[test]
fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() {
let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
let ink = [0.2, 0.7, 0.1, 0.3, 1.0];
let hue =
Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
let balance =
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
for adj in [hue, balance] {
let mut want = [[ink[0], ink[1], ink[2], ink[3]]];
adjust::apply_cmy(&adj, &mut want);
let got = adjusted_pixel(cmyk, &ink, &adj, None);
assert!((0..4).all(|c| (got[c] - want[0][c]).abs() < 1e-4), "{}: {got:?} vs {want:?}", adj.label());
assert!((got[3] - ink[3]).abs() < 1e-4 && got[4] == 1.0, "{}: black and alpha kept", adj.label());
let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5));
let k = 128.0 / 255.0;
assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label());
}
}
+240 -125
View File
@@ -257,6 +257,9 @@ pub struct Interface {
/// Show the repeating dot pattern on the canvas surround (including the empty workspace).
/// Disable for a uniform background, for example on OLED displays (#2264).
pub show_canvas_dots: bool,
/// List recent files on the Home screen (the empty workspace). Off hides the list; the
/// recent files themselves are kept and File > Open Recent still shows them.
pub show_home_recent_files: bool,
/// Document border in standard screen mode.
pub canvas_border: CanvasBorder,
pub ui_scale: UiScale,
@@ -269,6 +272,9 @@ pub struct Interface {
pub dynamic_color_sliders: bool,
/// Draw menu item colours set with Edit › Menus.
pub show_menu_colors: bool,
/// Layers panel thumbnails frame the layer's own pixels instead of the whole canvas, so small
/// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281).
pub clip_thumbnails_to_layer_bounds: bool,
pub show_tooltips: bool,
/// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new
/// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273).
@@ -298,6 +304,7 @@ impl Default for Interface {
canvas_color: CanvasColor::Default,
canvas_custom_color: "#282828".into(),
show_canvas_dots: true,
show_home_recent_files: true,
canvas_border: CanvasBorder::DropShadow,
ui_scale: UiScale::Auto,
language: "auto".into(),
@@ -305,6 +312,7 @@ impl Default for Interface {
show_channels_in_color: false,
dynamic_color_sliders: true,
show_menu_colors: true,
clip_thumbnails_to_layer_bounds: false,
show_tooltips: true,
enable_animations: true,
show_bounding_box_when_dragging_layer: false,
@@ -374,6 +382,13 @@ pub struct Tools {
/// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure
/// passes through before it reaches any brush ([`PressureCurve`]). Linear by default.
pub pressure_curve: Vec<[f32; 2]>,
/// Arrow-key nudges of one thing (the selected layers, the selection outline, the Free
/// Transform box) that follow each other within [`Tools::nudge_bundle_pause_ms`] are one
/// History state (#2259). Off: every press is a state.
pub bundle_nudges: bool,
/// The longest pause, in milliseconds, between two nudges that still bundle into one state.
/// Longer than a key's auto-repeat delay (about half a second), so a held key is one state.
pub nudge_bundle_pause_ms: u32,
}
impl Default for Tools {
@@ -392,104 +407,16 @@ impl Default for Tools {
right_click_with_painting_tools: RightClickPaint::BrushPicker,
use_tablet_pressure: true,
pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]],
bundle_nudges: true,
nudge_bundle_pause_ms: 1000,
}
}
}
/// Preferences › Tools › Pressure Curve, ready to evaluate: a monotone cubic (Fritsch–Carlson)
/// through the control points, so a firmer press never gives less pressure. The points are
/// sanitised here, not trusted: a whole-section update or a hand-edited preferences file can store
/// anything. Non-finite points are dropped, the rest clamped to 0..1, sorted by input (one point
/// per input), capped at [`Self::MAX_POINTS`], and their outputs made non-decreasing; before the
/// first point and after the last the curve stays flat. Fewer than two usable points is linear.
#[derive(Clone, Debug, PartialEq)]
pub struct PressureCurve {
xs: Vec<f32>,
ys: Vec<f32>,
/// Tangents at the points.
ms: Vec<f32>,
}
impl PressureCurve {
pub const MAX_POINTS: usize = 16;
pub fn new(points: &[[f32; 2]]) -> Self {
let mut pts: Vec<[f32; 2]> =
points.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
pts.sort_by(|a, b| a[0].total_cmp(&b[0]));
pts.dedup_by(|b, a| {
// Same input: keep the later point's output.
let same = (a[0] - b[0]).abs() < 1e-6;
if same {
a[1] = b[1];
}
same
});
pts.truncate(Self::MAX_POINTS);
if pts.len() < 2 {
pts = vec![[0.0, 0.0], [1.0, 1.0]];
}
let xs: Vec<f32> = pts.iter().map(|p| p[0]).collect();
let mut ys: Vec<f32> = pts.iter().map(|p| p[1]).collect();
for i in 1..ys.len() {
ys[i] = ys[i].max(ys[i - 1]);
}
// Fritsch–Carlson tangents: secant averages, zero at flat steps, limited so the cubic
// never overshoots (which keeps it monotone).
let n = xs.len();
let d: Vec<f32> = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect();
let mut ms = vec![0.0f32; n];
ms[0] = d[0];
ms[n - 1] = d[n - 2];
for k in 1..n - 1 {
ms[k] = if d[k - 1] * d[k] <= 0.0 { 0.0 } else { (d[k - 1] + d[k]) / 2.0 };
}
for k in 0..n - 1 {
if d[k] == 0.0 {
ms[k] = 0.0;
ms[k + 1] = 0.0;
continue;
}
let (a, b) = (ms[k] / d[k], ms[k + 1] / d[k]);
let s = a * a + b * b;
if s > 9.0 {
let t = 3.0 / s.sqrt();
ms[k] = t * a * d[k];
ms[k + 1] = t * b * d[k];
}
}
Self { xs, ys, ms }
}
/// The curve's output for pen pressure `x` (0..1; anything else is clamped, NaN reads as 0).
pub fn eval(&self, x: f32) -> f32 {
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
let n = self.xs.len();
let (Some(&x0), Some(&xn)) = (self.xs.first(), self.xs.last()) else { return x };
if x <= x0 {
return self.ys.first().copied().unwrap_or(x);
}
if x >= xn {
return self.ys.last().copied().unwrap_or(x);
}
let k = self.xs.partition_point(|&v| v <= x).saturating_sub(1).min(n.saturating_sub(2));
let (Some(&xa), Some(&xb), Some(&ya), Some(&yb), Some(&ma), Some(&mb)) =
(self.xs.get(k), self.xs.get(k + 1), self.ys.get(k), self.ys.get(k + 1), self.ms.get(k), self.ms.get(k + 1))
else {
return x;
};
let h = xb - xa;
let t = (x - xa) / h;
let (t2, t3) = (t * t, t * t * t);
let y = (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * ma + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * mb;
y.clamp(ya.min(yb), ya.max(yb))
}
/// The identity (the default curve): pressure passes through unchanged.
pub fn is_linear(&self) -> bool {
self.xs == [0.0, 1.0] && self.ys == [0.0, 1.0]
}
}
/// Preferences › Tools › Pressure Curve, ready to evaluate: the same monotone cubic every brush
/// dynamic uses for its own control ([`photocraft_paint::ResponseCurve`]), sanitised the same way,
/// since a whole-section update or a hand-edited preferences file can store anything.
pub use photocraft_paint::ResponseCurve as PressureCurve;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -521,6 +448,9 @@ pub struct FileHandling {
pub recover_on_launch: bool,
/// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB.
pub ignore_exif_profile_tag: bool,
/// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR
/// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead.
pub exr_aces_cg: bool,
pub ask_before_saving_layered_tiff: bool,
pub maximize_psd_compatibility: Ask,
/// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed.
@@ -540,6 +470,7 @@ impl Default for FileHandling {
autosave_minutes: 10,
recover_on_launch: true,
ignore_exif_profile_tag: false,
exr_aces_cg: false,
ask_before_saving_layered_tiff: true,
maximize_psd_compatibility: Ask::Always,
rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32,
@@ -954,6 +885,20 @@ pub struct MenuCustomization {
pub colors: BTreeMap<String, String>,
}
impl MenuCustomization {
/// False for the items Edit › Menus can't hide (#5096): the way back into this dialog
/// (Edit › Menus, Edit › Keyboard Shortcuts), Preferences, Save, Save As and Exit.
pub fn hideable(id: &str) -> bool {
!matches!(id, "edit.menus" | "edit.keyboardShortcuts" | "file.save" | "file.saveAs" | "file.exit") && !id.starts_with("edit.preferences")
}
/// Whether the menus leave `id` out. Items that can't be hidden stay shown even when an
/// older or hand-edited preferences file lists them.
pub fn is_hidden(&self, id: &str) -> bool {
Self::hideable(id) && self.hidden.iter().any(|h| h == id)
}
}
/// Edit › Toolbar: hidden tools and a custom order (tool names as in `ui.set {tool}`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -988,6 +933,11 @@ pub struct Preferences {
/// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty
/// string removes the default shortcut.
pub shortcuts: BTreeMap<String, String>,
/// Edit › Keyboard Shortcuts: a command's further keys besides its main one (#3665),
/// command id → keys. A list replaces the command's default alternates
/// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this
/// map load with nothing stored, so every command keeps its defaults.
pub alternate_shortcuts: BTreeMap<String, Vec<String>>,
pub menus: MenuCustomization,
pub toolbar: ToolbarCustomization,
/// Edit › Check Spelling: words added to the dictionary ("Add").
@@ -1015,6 +965,9 @@ pub struct Preferences {
/// differ from their defaults, saved as the user changes them and restored at launch, as
/// Photoshop remembers each tool's options between sessions. JSON owned by the shell.
pub tool_options: Value,
/// Last selected tool, restored at launch. A tool-name string owned and validated by the
/// shell; null in older files. Kept separate from tool options so neither overwrites the other.
pub selected_tool: Value,
/// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To
/// targets that differ from their defaults, saved as the user toggles them and restored at
/// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell.
@@ -1137,6 +1090,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
Some(match path {
"fileHandling.autosaveMinutes" => (1.0, 240.0),
"fileHandling.recentFileCount" => (0.0, 100.0),
"tools.nudgeBundlePauseMs" => (100.0, 10_000.0),
"fileHandling.rasterizeSvgGroupsDeeperThan" => (0.0, photocraft_doc::MAX_GROUP_DEPTH as f64),
"interface.notificationDurationSeconds" => (1.0, 120.0),
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
@@ -1158,7 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
/// Is this preference a `#rrggbb` colour?
pub fn is_color(path: &str) -> bool {
if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("menus.") {
if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") {
return false;
}
let leaf = path.rsplit('.').next().unwrap_or(path);
@@ -1187,9 +1141,10 @@ pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
// ------------------------------------------------------------------ JSON paths
/// Map keys that are command ids (which contain dots): `shortcuts.<id>`, `menus.colors.<id>`.
/// Map keys that are command ids (which contain dots): `shortcuts.<id>`,
/// `alternateShortcuts.<id>`, `menus.colors.<id>`.
fn keyed(path: &str) -> Option<(&str, &str)> {
for prefix in ["shortcuts.", "menus.colors."] {
for prefix in ["shortcuts.", "alternateShortcuts.", "menus.colors."] {
if let Some(id) = path.strip_prefix(prefix) {
return Some((&prefix[..prefix.len() - 1], id));
}
@@ -1306,6 +1261,28 @@ impl Preferences {
/// Set a value by dotted path, validating it. Unknown paths and wrong types are errors.
pub fn set(&mut self, path: &str, value: Value) -> std::result::Result<(), String> {
if path == "alternateShortcuts" {
let obj = value.as_object().ok_or("`alternateShortcuts` maps command ids to lists of shortcuts")?;
let mut map = BTreeMap::new();
for (id, v) in obj {
map.insert(id.clone(), alternate_keys(v).map_err(|e| format!("`alternateShortcuts.{id}`: {e}"))?);
}
self.alternate_shortcuts = map;
return Ok(());
}
if let Some(id) = path.strip_prefix("alternateShortcuts.") {
// null = back to the command's default alternates.
match value {
Value::Null => {
self.alternate_shortcuts.remove(id);
}
v => {
let keys = alternate_keys(&v).map_err(|e| format!("`{path}`: {e}"))?;
self.alternate_shortcuts.insert(id.to_string(), keys);
}
}
return Ok(());
}
let value = match (path.strip_prefix("shortcuts."), &value) {
// Normalise shortcut spelling so conflicts compare equal.
(Some(_), Value::String(s)) if !s.is_empty() => Value::String(normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut"))?),
@@ -1336,6 +1313,10 @@ impl Preferences {
self.shortcuts.remove(id);
return Ok(());
}
Some(("alternateShortcuts", id)) => {
self.alternate_shortcuts.remove(id);
return Ok(());
}
Some(("menus.colors", id)) => {
self.menus.colors.remove(id);
return Ok(());
@@ -1361,6 +1342,50 @@ impl Preferences {
None => default,
}
}
/// A command's further keys in effect (#3665): the user's list, else its defaults
/// ([`ALTERNATE_SHORTCUTS`]), normalised, without duplicates and without `primary` (the
/// command's main key in effect, which it already answers).
pub fn alternates(&self, id: &str, primary: Option<&str>) -> Vec<String> {
let main = primary.and_then(normalize_shortcut);
let keys: Vec<String> = match self.alternate_shortcuts.get(id) {
Some(list) => list.iter().filter_map(|s| normalize_shortcut(s)).collect(),
None => default_alternates(id).filter_map(normalize_shortcut).collect(),
};
let mut out: Vec<String> = Vec::with_capacity(keys.len());
for k in keys {
if main.as_ref() != Some(&k) && !out.contains(&k) {
out.push(k);
}
}
out
}
}
/// Most further keys one command may hold; Photoshop's own defaults use one.
pub const MAX_ALTERNATES: usize = 8;
/// Validate and normalise a command's alternate keys: a list of shortcut strings (a single
/// string is a one-key list, `""` none).
pub fn alternate_keys(v: &Value) -> std::result::Result<Vec<String>, String> {
let items: Vec<&Value> = match v {
Value::String(s) if s.is_empty() => Vec::new(),
Value::String(_) => vec![v],
Value::Array(a) => a.iter().collect(),
_ => return Err("alternate shortcuts are a list of shortcuts (e.g. [\"F3\"])".into()),
};
if items.len() > MAX_ALTERNATES {
return Err(format!("at most {MAX_ALTERNATES} alternate shortcuts (got {})", items.len()));
}
let mut out: Vec<String> = Vec::with_capacity(items.len());
for item in items {
let s = item.as_str().ok_or_else(|| format!("alternate shortcut `{item}` must be a string"))?;
let n = normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"))?;
if !out.contains(&n) {
out.push(n);
}
}
Ok(out)
}
// ------------------------------------------------------------------ shortcuts
@@ -1448,6 +1473,24 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
("tools.temporary.erase", "Erase with Brush or Pencil (hold)", "`"),
];
/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key
/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the
/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace
/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In.
pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
("edit.cut", "F2"),
("edit.copy", "F3"),
("edit.paste", "F4"),
("edit.fill", "Shift+Backspace"),
("select.inverse", "Shift+F7"),
("view.zoomIn", "Cmd++"),
];
/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]).
pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
}
/// Every bindable id with its default: commands, then the temporary tools.
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
@@ -1602,7 +1645,7 @@ impl Session {
}
let mut next = self.prefs().clone();
let mut color_changed = false;
if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "menus" | "toolbar") {
if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "alternateShortcuts" | "menus" | "toolbar") {
// Whole section (or everything): merge the object key by key so each value is checked.
let obj = value.as_object().ok_or_else(|| format!("`{path}` needs an object"))?;
for (k, v) in obj {
@@ -1611,7 +1654,12 @@ impl Session {
color_changed |= self.set_pref("colorSettings", v.clone(), validate_color)?;
continue;
}
if v.is_object() && !p.starts_with("shortcuts") && !p.ends_with(".colors") && next.get(&p).is_some_and(|c| c.is_object()) {
if v.is_object()
&& !p.starts_with("shortcuts")
&& !p.starts_with("alternateShortcuts")
&& !p.ends_with(".colors")
&& next.get(&p).is_some_and(|c| c.is_object())
{
for (k2, v2) in v.as_object().into_iter().flatten() {
next.set(&format!("{p}.{k2}"), v2.clone())?;
}
@@ -1787,20 +1835,29 @@ fn preferences_section(s: &mut Session, p: &Value) -> Result<Value> {
/// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts.
fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.keyboardShortcuts";
// Every change lands at once, after all of it validated: a bad key leaves the set as it was.
let mut next = s.prefs().clone();
let mut changed = false;
if let Some(r) = p.get("reset") {
match r {
Value::Bool(true) => s.edit_prefs(|p| p.shortcuts.clear()),
Value::Array(ids) => s.edit_prefs(|p| {
Value::Bool(true) => {
next.shortcuts.clear();
next.alternate_shortcuts.clear();
changed = true;
}
Value::Array(ids) => {
for id in ids.iter().filter_map(Value::as_str) {
p.shortcuts.remove(id);
next.shortcuts.remove(id);
next.alternate_shortcuts.remove(id);
}
}),
changed = true;
}
_ => {}
}
}
// A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` entries, matched by label
// (`crate::kys`); a key equal to the command's default restores the default, and keys given
// in `set` as well win over the file's.
// A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` and `alternates` entries,
// matched by label (`crate::kys`); a key equal to the command's default restores the
// default, and keys given in `set` / `alternates` as well win over the file's.
let imported;
let mut import = None;
let p = match p.get("importKys") {
@@ -1814,6 +1871,13 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) });
}
merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default());
let mut alternates = serde_json::Map::new();
for (id, keys) in &plan.alternate_keys {
let main = plan.set.get(id).map(String::as_str);
let default = Preferences::default().alternates(id, main);
alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) });
}
alternates.extend(p.get("alternates").and_then(Value::as_object).cloned().unwrap_or_default());
import = Some(json!({
"name": set.name,
"imported": plan.set.len(),
@@ -1821,41 +1885,73 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"unknown": plan.unknown,
"unreadable": plan.unreadable,
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
}));
let mut next = p.clone();
next["set"] = Value::Object(merged);
imported = next;
let mut with_import = p.clone();
with_import["set"] = Value::Object(merged);
with_import["alternates"] = Value::Object(alternates);
imported = with_import;
&imported
}
None => p,
};
if let Some(m) = p.get("set").and_then(Value::as_object) {
let mut next = s.prefs().clone();
for (id, v) in m {
if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) {
let empty = serde_json::Map::new();
let set = match p.get("set") {
Some(Value::Object(m)) => m,
_ => &empty,
};
let alts = match p.get("alternates") {
None | Some(Value::Null) => &empty,
Some(v) => v.as_object().ok_or_else(|| bad(cmd, "`alternates` maps command ids to lists of shortcuts"))?,
};
if !set.is_empty() || !alts.is_empty() {
let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false);
changed = true;
for (id, v) in set {
if !bindable().any(|(b, _)| b == id) && !allow_unknown {
return Err(bad(cmd, format!("unknown command `{id}`")));
}
next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?;
}
// Like Photoshop, a shortcut moved to a new command is taken away from the old one.
for (id, v) in alts {
if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown {
return Err(bad(cmd, format!("unknown command `{id}`")));
}
next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?;
}
// Like Photoshop, a shortcut moved to a new command is taken away from the old one,
// whether the old one held it as its main key or as an alternate (#3665).
if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) {
for (id, v) in m {
let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue };
for (c, def) in bindable() {
let assigned: Vec<(&str, String)> = set
.iter()
.filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?)))
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
.collect();
for (id, sc) in assigned {
// Shell commands (View › Zoom In) have alternates but no engine default.
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
for (c, def) in bindable().chain(shell) {
// Never strip a command that this same call is assigning:
// two entries for one key are a clash inside the call, which
// the returned conflicts list reports (#719). Stripping
// each other here unbound both silently.
if c == id || m.contains_key(c) {
if c == id || set.contains_key(c) || alts.contains_key(c) {
continue;
}
if next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) {
let main = next.shortcut(c, def).map(str::to_string);
if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) {
next.shortcuts.insert(c.to_string(), String::new());
}
let held = next.alternates(c, main.as_deref());
if held.contains(&sc) {
next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect());
}
}
}
}
}
if changed {
s.edit_prefs(|p| *p = next);
}
let prefs = s.prefs();
@@ -1866,18 +1962,34 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
.chain(temporary)
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
.filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.0))
.filter(|c| {
p.get("list").and_then(Value::as_bool).unwrap_or(false)
|| filter.is_some()
|| prefs.shortcuts.contains_key(c.0)
|| prefs.alternate_shortcuts.contains_key(c.0)
})
.map(|(id, label, menu, def, hold)| {
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)});
let main = prefs.shortcut(id, def);
let defaults: Vec<&str> = default_alternates(id).collect();
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
if !defaults.is_empty() {
v["defaultAlternates"] = json!(defaults);
}
if hold {
v["hold"] = json!(true);
}
v
})
.collect();
let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect();
let conflicts: Vec<Value> = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
let mut out = json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts});
let mut bindings: Vec<(&str, String)> = Vec::new();
for (id, def) in bindable() {
let main = prefs.shortcut(id, def);
bindings.extend(main.map(|m| (id, m.to_string())));
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
}
let conflicts: Vec<Value> =
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
if let Some(import) = import {
out["import"] = import;
}
@@ -1898,6 +2010,9 @@ fn menus(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let (hide, show) = (ids("hide"), ids("show"));
if let Some(id) = hide.iter().find(|id| !MenuCustomization::hideable(id)) {
return Err(bad("edit.menus", format!("`{id}` can't be hidden")));
}
s.edit_prefs(|pr| {
if p.get("reset").and_then(Value::as_bool) == Some(true) {
pr.menus = MenuCustomization::default();
@@ -2006,7 +2121,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##,
keyboard_shortcuts,
true
),
+193
View File
@@ -49,6 +49,24 @@ fn canvas_dot_grid_can_be_disabled_persisted_and_reset() {
assert!(s.prefs().interface.show_canvas_dots);
}
/// UI-217-10: the Home screen's recent-files list can be switched off and the choice persists.
#[test]
fn home_recent_files_preference_defaults_on_persists_and_resets() {
let mut s = Session::new();
assert!(s.prefs().interface.show_home_recent_files);
s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": false})).unwrap();
assert!(!s.prefs().interface.show_home_recent_files);
let mut restored = Session::new();
restored.load_prefs_json(&s.prefs_to_json()).unwrap();
assert!(!restored.prefs().interface.show_home_recent_files);
let mut legacy = Session::new();
legacy.load_prefs_json(r#"{"interface": {"theme": "studio"}}"#).unwrap();
assert!(legacy.prefs().interface.show_home_recent_files);
assert!(s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": 3})).is_err());
s.execute("prefs.reset", json!({"path": "interface.showHomeRecentFiles"})).unwrap();
assert!(s.prefs().interface.show_home_recent_files);
}
#[test]
fn appearance_defaults_and_legacy_theme_migrate() {
let p = Preferences::default();
@@ -397,6 +415,19 @@ fn menus_and_toolbar_customisation_persist() {
assert_eq!(back.toolbar.hidden, vec!["Sponge".to_string()]);
}
/// #5096: Edit › Menus can't hide the way back into itself, Preferences, Save or Exit.
#[test]
fn menus_refuse_to_hide_essential_items() {
let mut s = session();
for id in ["edit.menus", "edit.keyboardShortcuts", "edit.preferences.general", "file.save", "file.saveAs", "file.exit"] {
assert!(s.execute("edit.menus", json!({"hide": ["edit.fade", id]})).is_err(), "{id}");
assert!(s.prefs().menus.hidden.is_empty(), "{id}: a refused call changes nothing");
}
// A preferences file that lists them anyway still shows them.
let menus = MenuCustomization { hidden: vec!["file.exit".into(), "edit.fade".into()], ..Default::default() };
assert!(!menus.is_hidden("file.exit") && menus.is_hidden("edit.fade"));
}
#[test]
fn preference_sections_are_commands() {
let mut s = Session::new();
@@ -625,6 +656,168 @@ fn ruler_units_round_to_their_own_decimal_places() {
assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333");
}
/// #2259: arrow-key nudges bundle into one History state by default, within a pause long enough
/// for a held key's auto-repeat delay; both are Tools preferences with a validated range.
#[test]
fn nudge_bundling_preferences() {
let p = Preferences::default();
assert!(p.tools.bundle_nudges);
assert_eq!(p.tools.nudge_bundle_pause_ms, 1000);
// Preferences saved before these existed load with the defaults.
let old: Preferences = serde_json::from_value(json!({"tools": {"showTooltips": false}})).unwrap();
assert!(old.tools.bundle_nudges && !old.tools.show_tooltips);
assert_eq!(old.tools.nudge_bundle_pause_ms, 1000);
let mut s = session();
assert_eq!(s.execute("prefs.get", json!({"path": "tools.bundleNudges"})).unwrap(), json!(true));
s.execute("prefs.set", json!({"values": {"tools.bundleNudges": false, "tools.nudgeBundlePauseMs": 250}})).unwrap();
assert!(!s.prefs().tools.bundle_nudges);
assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 250);
for edge in [100, 10_000] {
s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": edge})).unwrap();
assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, edge);
}
for bad in [json!(0), json!(99), json!(10_001), json!(-1), json!(1.5e300), json!("long"), json!(null)] {
assert!(s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": bad.clone()})).is_err(), "{bad}");
}
assert!(s.execute("prefs.set", json!({"path": "tools.bundleNudges", "value": "yes"})).is_err());
assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 10_000, "a rejected value changes nothing");
s.execute("prefs.reset", json!({"path": "tools"})).unwrap();
assert!(s.prefs().tools.bundle_nudges);
assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 1000);
}
/// #3665: Photoshop's second default keys (F2/F3/F4, Shift+F7, Cmd++) are a command's
/// alternates, beside the main key its menu shows.
#[test]
fn commands_have_photoshops_alternate_keys() {
let p = Preferences::default();
let main = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut);
assert_eq!(main("edit.cut"), Some("Cmd+X"));
assert_eq!(p.alternates("edit.cut", main("edit.cut")), ["F2"]);
assert_eq!(p.alternates("edit.copy", main("edit.copy")), ["F3"]);
assert_eq!(p.alternates("edit.paste", main("edit.paste")), ["F4"]);
assert_eq!(main("select.inverse"), Some("Cmd+Shift+I"));
assert_eq!(p.alternates("select.inverse", main("select.inverse")), ["Shift+F7"]);
assert_eq!(p.alternates("edit.fill", main("edit.fill")), ["Shift+Backspace"]);
assert_eq!(p.alternates("view.zoomIn", Some("Cmd+=")), ["Cmd++"]);
assert!(p.alternates("edit.undo", main("edit.undo")).is_empty());
// No alternate is another command's main key or another command's alternate.
let mut seen = std::collections::HashMap::new();
for c in crate::command_specs() {
if let Some(sc) = c.shortcut.and_then(normalize_shortcut) {
seen.insert(sc, c.id);
}
}
for (id, sc) in ALTERNATE_SHORTCUTS {
let n = normalize_shortcut(sc).unwrap();
assert!(seen.insert(n, id).is_none(), "{sc} of {id} is already bound");
}
// A command's own main key is never repeated as its alternate.
assert!(p.alternates("edit.copy", Some("F3")).is_empty());
}
/// Settings files written before alternates existed still load, keeping their overrides and
/// gaining the default alternates.
#[test]
fn old_settings_without_alternates_load() {
let old: Preferences = serde_json::from_str(r#"{"shortcuts": {"edit.copy": "Cmd+K", "edit.undo": ""}}"#).unwrap();
assert_eq!(old.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K"));
assert!(old.alternate_shortcuts.is_empty());
// Overriding the main key keeps the alternate (Photoshop edits each row on its own).
assert_eq!(old.alternates("edit.copy", old.shortcut("edit.copy", Some("Cmd+C"))), ["F3"]);
// And a file with alternates round-trips.
let mut p = old.clone();
p.set("alternateShortcuts.edit.copy", json!(["f9", "F9"])).unwrap();
let back: Preferences = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back.alternate_shortcuts.get("edit.copy"), Some(&vec!["F9".to_string()]));
}
#[test]
fn alternate_shortcuts_set_reset_and_reject_bad_input() {
let mut s = session();
// Through the command: a list replaces the defaults, [] removes them, null restores them.
s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9"]}})).unwrap();
assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F9"]);
let r = s.execute("edit.keyboardShortcuts", json!({"filter": "edit.copy"})).unwrap();
let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.copy").unwrap().clone();
assert_eq!(row["alternates"], json!(["F9"]));
assert_eq!(row["defaultAlternates"], json!(["F3"]));
s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": []}})).unwrap();
assert!(s.prefs().alternates("edit.copy", Some("Cmd+C")).is_empty());
s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": null}})).unwrap();
assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F3"]);
// Through prefs.set / prefs.reset by path.
s.execute("prefs.set", json!({"path": "alternateShortcuts.edit.cut", "value": "F10"})).unwrap();
assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F10"]);
s.execute("prefs.reset", json!({"path": "alternateShortcuts.edit.cut"})).unwrap();
assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F2"]);
// Reset all clears alternates too.
s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.paste": []}})).unwrap();
s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap();
assert!(s.prefs().alternate_shortcuts.is_empty());
// Bad input is an error, not a panic, and changes nothing.
for bad in [
json!({"alternates": {"edit.copy": "Cmd+Shift"}}),
json!({"alternates": {"edit.copy": [3]}}),
json!({"alternates": {"edit.copy": {"a": 1}}}),
json!({"alternates": {"edit.copy": vec!["F1"; MAX_ALTERNATES + 1]}}),
json!({"alternates": {"no.such.command": ["F9"]}}),
json!({"alternates": "F3"}),
] {
assert!(s.execute("edit.keyboardShortcuts", bad.clone()).is_err(), "{bad}");
}
assert!(s.prefs().alternate_shortcuts.is_empty());
// A bad key in the same call as a reset leaves every shortcut as it was.
s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "Cmd+K"}})).unwrap();
assert!(s.execute("edit.keyboardShortcuts", json!({"reset": true, "alternates": {"edit.copy": ["Cmd+Shift"]}})).is_err());
assert_eq!(s.prefs().shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K"));
assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": {"edit.copy": ["Cmd+Shift"]}})).is_err());
assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": 7})).is_err());
}
/// A key given to another command is taken from the command that held it as an alternate
/// (Photoshop moves a shortcut rather than duplicating it), and kept duplicates are reported.
#[test]
fn moving_an_alternate_key_takes_it_from_its_holder() {
let mut s = session();
// F2 is Cut's alternate; Rename Layer may take it (#2405).
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}, "removeConflicts": false})).unwrap();
assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["shortcut"] == "F2"), "{r}");
s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap();
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}})).unwrap();
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
assert!(s.prefs().alternates("edit.cut", Some("Cmd+X")).is_empty());
assert_eq!(s.prefs().shortcut("edit.cut", Some("Cmd+X")), Some("Cmd+X"), "Cut keeps its main key");
// An alternate assigned to another command takes a main key too.
s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["Cmd+Z"]}})).unwrap();
assert_eq!(s.prefs().shortcut("edit.undo", Some("Cmd+Z")), None);
// Overriding the main key keeps the alternates.
s.execute("edit.keyboardShortcuts", json!({"reset": true, "set": {"edit.copy": "Cmd+K"}})).unwrap();
assert_eq!(s.prefs().alternates("edit.copy", s.prefs().shortcut("edit.copy", Some("Cmd+C"))), ["F3"]);
}
/// #3665: a `.kys` row listing a command's default and a function key keeps both; the old
/// import kept only the first key.
#[test]
fn kys_import_keeps_a_rows_second_key() {
let mut s = session();
let xml = "<photoshop-keyboard-shortcuts>\
<command name=\"Copy\"><shortcut>Cmd+C</shortcut><shortcut>F3</shortcut></command>\
<command name=\"Cut\"><shortcut>Cmd+X</shortcut></command>\
<command name=\"Undo\"><shortcut>Cmd+Z</shortcut><shortcut>F1</shortcut></command>\
</photoshop-keyboard-shortcuts>";
let r = s.execute("edit.keyboardShortcuts", json!({"importKys": xml})).unwrap();
assert_eq!(r["import"]["alternates"], 2);
let p = s.prefs();
assert_eq!(p.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+C"));
assert_eq!(p.alternates("edit.copy", Some("Cmd+C")), ["F3"]);
assert!(!p.alternate_shortcuts.contains_key("edit.copy"), "the default alternate needs no override");
// A row without Cut's F2 removes it; Undo gains F1.
assert!(p.alternates("edit.cut", Some("Cmd+X")).is_empty());
assert_eq!(p.alternates("edit.undo", Some("Cmd+Z")), ["F1"]);
}
/// #3296: like Photoshop, "Use Shift Key for Tool Switch" is on by default; a stored choice wins.
#[test]
fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() {
+164
View File
@@ -4,6 +4,9 @@
//! library as one group named after the file, with the file's own folders nested inside it
//! as Photoshop does (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in
//! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`).
//! - `brush.presets.importImage` turns an image file (anything File › Open reads: PNG, JPEG,
//! TIFF, PSD…) into a sampled-tip brush preset, as Edit › Define Brush Preset does with a
//! selection: dark pixels paint, white and transparent ones don't (TOOL-213-12).
//! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the
//! Gradients panel as one group.
//!
@@ -115,6 +118,87 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings }))
}
/// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a
/// selection), cropped to the painted extent. The composite is rendered in bands twice: once to
/// find the painted bounds, then only those, so a large mostly-white image never needs a
/// full-size buffer.
fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec<f32>, usize, usize)> {
let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0);
let mut bounds: Option<photocraft_geom::Rect> = None;
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> {
let w = band.rect.width() as usize;
for (i, c) in band.px.iter().enumerate() {
if paint(*c) > 1e-4 {
let (x, y) = (band.rect.x0 + (i % w.max(1)) as i32, band.rect.y0 + (i / w.max(1)) as i32);
let px = photocraft_geom::Rect::new(x, y, x.saturating_add(1), y.saturating_add(1));
bounds = Some(bounds.map_or(px, |b| b.union(&px)));
}
}
Ok(())
});
let area = bounds.ok_or_else(|| bad(cmd, "the image contains no paint (all white or transparent)"))?;
let max = crate::brush_cmds::MAX_TIP;
if area.width().max(area.height()) > max {
return Err(bad(cmd, format!("the painted part of the image is too large for a brush tip ({}×{}; max {max} px)", area.width(), area.height())));
}
let (w, h) = (area.width() as usize, area.height() as usize);
let mut v = vec![0.0f32; w * h];
let _ = photocraft_compose::render_bands(doc, area, 0, |band| -> std::result::Result<(), ()> {
let bw = band.rect.width() as usize;
for (i, c) in band.px.iter().enumerate() {
let (x, y) = ((band.rect.x0 - area.x0) as usize + i % bw.max(1), (band.rect.y0 - area.y0) as usize + i / bw.max(1));
if let Some(out) = v.get_mut(y * w + x) {
*out = paint(*c);
}
}
Ok(())
});
Ok((v, w, h))
}
/// Brushes panel › Import Brushes… with an image file: a new sampled-tip preset named after the
/// file (never overwriting one), made the current brush unless `select` is false.
fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.importImage";
let (bytes, stem) = file_bytes(p, cmd, "image file")?;
let file_name = match p.get("fileName") {
None => None,
Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`fileName` must be a non-empty string"))?),
};
// The decoder picks the format by content, falling back to the name's extension.
let decode_name = p.get("path").and_then(Value::as_str).or(file_name).unwrap_or("image");
let stem = if stem.is_empty() {
file_name.and_then(|n| std::path::Path::new(n).file_stem()).map(|s| s.to_string_lossy().trim().to_string()).unwrap_or_default()
} else {
stem
};
let name = match p.get("name") {
None if stem.is_empty() => "Imported Brush".to_string(),
None => stem,
Some(v) => v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` must be a non-empty string"))?.to_string(),
};
let doc = crate::file_cmds::import(decode_name, &bytes)?;
let (v, w, h) = image_paint(&doc, cmd)?;
let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?;
let taken: Vec<String> = s.tools.presets.iter().map(|x| x.name.clone()).collect();
let name = unique(&name, &taken);
s.tools.presets.push(photocraft_paint::BrushPreset {
name: name.clone(),
brush: brush.clone(),
builtin: false,
group: String::new(),
folder: Vec::new(),
captures: Default::default(),
});
s.brush_presets_changed();
if p.get("select").and_then(Value::as_bool).unwrap_or(true) {
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
}
Ok(json!({ "name": name, "width": tw, "height": th }))
}
/// Brush-file import command specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -128,6 +212,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: import_abr,
journal: true,
},
CommandSpec {
id: "brush.presets.importImage",
label: "Import Brush from Image…",
menu: &[],
shortcut: None,
params: r##"{"path":"image file (PNG, JPEG, TIFF, PSD…)"?,"data":base64 bytes?,"fileName":string? (with data: the file's name, for its format and the default preset name),"name":string?=file name,"select":bool=true (make it the current brush)} → {name, width, height}"##,
enabled: always,
run: import_image,
journal: true,
},
CommandSpec {
id: "gradient.presets.importGrd",
label: "Import Gradients…",
@@ -362,6 +456,76 @@ mod tests {
assert!(k.iter().all(|v| *v < 0.01), "{k:?}");
}
/// A 40×30 16-bit RGBA PNG: transparent, a white opaque band (no paint) and a black 10×6 block
/// at (5, 8) (full paint).
fn stamp_png16() -> Vec<u8> {
let mut px = vec![0u16; 40 * 30 * 4];
for y in 0..30 {
for x in 0..40 {
let i = (y * 40 + x) * 4;
if (5..15).contains(&x) && (8..14).contains(&y) {
px[i + 3] = u16::MAX;
} else if y >= 20 {
px[i..i + 4].copy_from_slice(&[u16::MAX; 4]);
}
}
}
let img = photocraft_codecs::Image::from_u16(40, 30, photocraft_codecs::ChannelLayout::Rgba, &px).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
#[test]
fn imports_an_image_as_a_brush_tip() {
use photocraft_paint::TipShape;
let mut s = Session::new();
let data = photocraft_paint::tile::b64_encode(&stamp_png16());
let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png"})).unwrap();
assert_eq!(r, json!({"name": "Stamp", "width": 10, "height": 6}), "cropped to the dark block");
let pr = s.tools.presets.iter().find(|p| p.name == "Stamp").unwrap();
let TipShape::Sampled(tip) = &pr.brush.tip else { panic!("sampled tip expected") };
assert_eq!((tip.width, tip.height), (10, 6));
assert!((pr.brush.size - 10.0).abs() < 1e-6);
assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp"), "the imported brush is current");
assert!(matches!(s.tools.brush.tip, TipShape::Sampled(_)));
// A second import keeps the first: a new name, and `select: false` leaves the brush alone.
let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png", "select": false})).unwrap();
assert_eq!(r["name"], "Stamp 2");
assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp"));
// An 8-bit grayscale JPEG from disk: named after the file.
let px: Vec<u8> = (0..24 * 24).map(|i| if (8..16).contains(&(i % 24)) && (4..20).contains(&(i / 24)) { 0 } else { 255 }).collect();
let img = photocraft_codecs::Image::from_u8(24, 24, photocraft_codecs::ChannelLayout::Gray, px).unwrap();
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
let path = std::env::temp_dir().join(format!("pc-brush-tip-{}.jpg", std::process::id()));
std::fs::write(&path, jpeg).unwrap();
let r = s.execute("brush.presets.importImage", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
let r = r.unwrap();
assert!(r["name"].as_str().unwrap().starts_with("pc-brush-tip-"), "{r}");
let (w, h) = (r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap());
assert!((8..=10).contains(&w) && (16..=18).contains(&h), "JPEG ringing aside, the bar: {r}");
}
#[test]
fn image_brush_import_fails_cleanly() {
let mut s = Session::new();
let white = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Gray, vec![255; 16]).unwrap();
let white = photocraft_paint::tile::b64_encode(&photocraft_codecs::encode(&white, photocraft_codecs::Format::Png, &Default::default()).unwrap());
let n = s.tools.presets.len();
for p in [
json!({}),
json!({"data": white}),
json!({"data": "@@"}),
json!({"data": photocraft_paint::tile::b64_encode(b"not an image")}),
json!({"data": white, "name": 3}),
json!({"data": white, "fileName": ""}),
json!({"path": "/nope/brush.png"}),
] {
assert!(s.execute("brush.presets.importImage", p.clone()).is_err(), "{p}");
}
assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing");
}
#[test]
fn unique_names() {
let taken = vec!["A".to_string(), "a 2".to_string()];
+9
View File
@@ -83,6 +83,8 @@ pub struct PresetState {
pub documents: Vec<crate::document_preset_cmds::DocumentPreset>,
/// File › New › Recent: the settings of documents recently created there, newest first.
pub recent_documents: Vec<crate::document_preset_cmds::DocumentSettings>,
/// File › Export › Export As: named export configurations.
pub export_presets: Vec<crate::export_preset_cmds::ExportPreset>,
pub clone: clone_source::CloneSources,
/// The user defaults "Make Default" in the Layer Style dialog saves
/// (effect kind → param set); "Reset to Default" restores them.
@@ -105,6 +107,7 @@ impl Default for PresetState {
tool_presets: tools::builtin(),
documents: Vec::new(),
recent_documents: Vec::new(),
export_presets: Vec::new(),
clone: Default::default(),
layer_defaults: BTreeMap::new(),
swatches: swatches::builtin(),
@@ -135,6 +138,8 @@ struct Persisted {
#[serde(default)]
recent_documents: Option<Value>,
#[serde(default)]
export_presets: Option<Value>,
#[serde(default)]
custom_shapes: Option<Vec<crate::edit_menu_cmds::CustomShape>>,
#[serde(default)]
layer_defaults: Option<BTreeMap<String, Value>>,
@@ -153,6 +158,7 @@ impl PresetState {
tool_presets: Some(json!(self.tool_presets)),
documents: Some(json!(self.documents)),
recent_documents: Some(json!(self.recent_documents)),
export_presets: Some(json!(self.export_presets)),
custom_shapes: Some(s.edit_state.custom_shapes.clone()),
layer_defaults: Some(self.layer_defaults.clone()),
swatches: Some(self.swatches.clone()),
@@ -191,6 +197,9 @@ impl Session {
if let Some(r) = p.recent_documents {
st.recent_documents = crate::document_preset_cmds::load_recent(r);
}
if let Some(e) = p.export_presets {
st.export_presets = crate::export_preset_cmds::load(e);
}
if let Some(c) = p.custom_shapes {
self.edit_state.custom_shapes = c;
}
+59 -28
View File
@@ -30,10 +30,6 @@ fn mode(p: &Value) -> SelectionMode {
/// A Color Range replacement is scoped to the selection that was active when it opened.
/// Explicit add/subtract/intersect operations retain their normal selection-combine semantics.
fn point_in_selection(doc: &Document, x: i32, y: i32) -> bool {
doc.selection.as_ref().is_none_or(|selection| selection.sample_channel(x, y, 0) > 0.0)
}
fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionMode) {
if m != SelectionMode::Replace {
return;
@@ -50,10 +46,17 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM
fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let area = d.doc.bounds();
// Match the decoders' raster budget before either the layer or composite path allocates.
let len = area.size().area();
let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 {
return Err(EngineError::Other("the canvas is too large to sample for a selection".into()));
}
let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface());
match (all_layers, layer) {
(false, Some(surf)) => {
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
let len = usize::try_from(len).map_err(|_| EngineError::Other("the canvas is too large to sample for a selection".into()))?;
let mut px = crate::allocation::filled(len, [0.0f32; 4], "sampling pixels for a selection")?;
surf.read_rgba_into(area, &mut px);
Ok((area, px))
}
@@ -146,14 +149,7 @@ fn parse_color(p: &Value) -> [f32; 3] {
/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point
/// and where it was picked (area-local, for Localized Color Clusters).
fn eyedropper_samples(
p: &Value,
key: &str,
area: Rect,
px: &[[f32; 4]],
allowed: &impl Fn(i32, i32) -> bool,
bad: &impl Fn(String) -> EngineError,
) -> Result<Vec<sel::RangeSample>> {
fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result<Vec<sel::RangeSample>> {
let Some(pts) = p.get(key) else { return Ok(Vec::new()) };
let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?;
let w = area.width() as usize;
@@ -171,9 +167,6 @@ fn eyedropper_samples(
let Some(&[r, g, b, _]) = q else {
return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas")));
};
if !allowed(xi, yi) {
return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection")));
}
out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) });
}
Ok(out)
@@ -244,9 +237,6 @@ impl ColorRangeSamples {
if !area.contains(x, y) {
return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas")));
}
if mode(p) == SelectionMode::Replace && !point_in_selection(doc, x, y) {
return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection")));
}
let r = Rect::from_xywh(x, y, 1, 1);
let pixel = if !b(p, "sampleAllLayers", true) {
s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).and_then(|l| l.surface()).map(|surf| {
@@ -370,10 +360,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;
let w = area.width() as usize;
// The dialog's eyedroppers: colours picked on the image, at their positions.
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let allowed = |x, y| selection_mode != SelectionMode::Replace || point_in_selection(doc, x, y);
let plus = eyedropper_samples(p, "points", area, &px, &allowed, &bad)?;
let minus = eyedropper_samples(p, "subtractPoints", area, &px, &allowed, &bad)?;
// Like Photoshop's, they may sample anywhere on the canvas: an active selection only
// limits the result (scope_color_range), not where colours are picked (#5244).
let plus = eyedropper_samples(p, "points", area, &px, &bad)?;
let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?;
let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?;
let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]);
(area, mask)
@@ -770,6 +760,38 @@ mod tests {
assert!(err.to_string().contains("detectFaces"), "{err}");
}
#[test]
fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() {
let mut s = Session::new();
s.add_document(
photocraft_doc::Document::new(
"large",
photocraft_geom::Size::new(300_000, 300_000),
photocraft_color::ColorMode::Rgb,
photocraft_color::SampleType::U8,
),
None,
);
for all_layers in [false, true] {
assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample"));
}
assert!(!s.active().unwrap().history.can_undo());
}
#[test]
fn selection_sampling_allocation_failure_keeps_the_selection() {
let mut s = session();
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.entries();
crate::allocation::fail_next_for_test();
let result = s.execute("select.similar", json!({"sampleAllLayers": false}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().history.entries(), history);
}
#[test]
fn modify_rejects_oversized_canvases_before_reading_the_mask() {
let mut s = Session::new();
@@ -829,15 +851,24 @@ mod tests {
assert_eq!(coverage(&s, 30, 10), 0.0);
}
#[test]
fn color_range_eyedropper_samples_anywhere_but_the_result_stays_in_the_selection() {
// #5244: as in Photoshop, a colour picked outside the active selection is accepted; only
// the result is limited to the selection.
let mut s = session();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
let outside = json!({"points": [[30, 10]], "fuzziness": 0});
assert!(ColorRangeSamples::new(&s, &outside).is_ok());
s.execute("select.colorRange", outside).unwrap();
assert_eq!(coverage(&s, 10, 10), 1.0, "the red inside the selection is selected");
assert_eq!(coverage(&s, 30, 10), 0.0, "the sampled red outside it stays out");
assert_eq!(coverage(&s, 2, 2), 0.0, "white is not the sampled colour");
}
#[test]
fn color_range_eyedropper_respects_selection_but_add_can_extend_it() {
let mut s = session();
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
let before = current_mask(&s.active().unwrap().doc).1;
let outside = json!({"points": [[30, 10]], "fuzziness": 0});
assert!(s.execute("select.colorRange", outside.clone()).unwrap_err().to_string().contains("outside the active selection"));
assert!(ColorRangeSamples::new(&s, &outside).err().unwrap().to_string().contains("outside the active selection"));
assert_eq!(current_mask(&s.active().unwrap().doc).1, before);
s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 0})).unwrap();
assert_eq!(coverage(&s, 10, 10), 1.0);
assert_eq!(coverage(&s, 30, 10), 0.0);
+7 -4
View File
@@ -827,21 +827,24 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp
// Keep the shared-instance transaction out of the per-command source-builder instantiations.
fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result<Value> {
let (parent, contents_id) = s.edit(label, |doc, _| {
let (parent, contents_id, changed) = s.edit(label, |doc, _| {
let contents_id = smart(doc, id)?.contents_id;
for instance in instances(doc, contents_id) {
let changed = instances(doc, contents_id);
for &instance in &changed {
smart_mut(doc, instance)?.source = new.clone();
if !keep_psd {
detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?);
}
refresh_or_fail(doc, instance)?;
}
Ok((doc.id, contents_id))
Ok((doc.id, contents_id, changed))
})?;
// An old editor must not overwrite newly replaced/relinked contents on close. Keep its
// document open, but the next Edit Contents opens the replacement source.
s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id);
Ok(json!({"layer": id.0}))
// Every instance changed, not only `layer`: list them, so a caller doesn't repeat the
// command once per copy (#4075).
Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
fn replace_contents(s: &mut Session, p: &Value) -> Result<Value> {
+73
View File
@@ -767,6 +767,38 @@ fn command_list_reports_enablement() {
assert_eq!(find("layer.new.layer")["enabled"], false);
}
/// The Elliptical and Single Row/Column Marquee are `select.rect` options: a command-list filter
/// for "ellipse" or "marquee" finds it through its keywords (#5060).
#[test]
fn command_list_filter_finds_the_marquees_by_keyword() {
let mut s = Session::new();
let list = s.execute("command.list", json!({})).unwrap();
let rows = list.as_array().unwrap();
let found = |needle: &str| -> Vec<String> {
rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect()
};
for needle in ["ellipse", "Elliptical Marquee", "marquee", "single row", "SELECT.RECT"] {
assert!(found(needle).contains(&"select.rect".to_string()), "{needle}: {:?}", found(needle));
}
let rect = rows.iter().find(|c| c["id"] == "select.rect").unwrap();
assert!(rect["keywords"].as_str().is_some_and(|k| k.contains("Elliptical Marquee")), "{rect}");
// Commands without keywords don't get an empty field, and the filter still reads id and label.
assert!(rows.iter().find(|c| c["id"] == "file.new").unwrap().get("keywords").is_none());
assert_eq!(found("gaussian blur"), ["filter.blur.gaussianBlur"]);
assert!(found("no command is called this").is_empty());
}
/// Every keyword entry names a real command, so a renamed id can't leave one behind.
#[test]
fn search_keywords_name_real_commands() {
for (id, words) in crate::commands::SEARCH_KEYWORDS {
assert!(crate::commands::find(id).is_some(), "keywords for unknown command {id}");
assert!(!words.trim().is_empty(), "{id}");
}
assert!(crate::commands::search_keywords("select.rect").is_some());
assert!(crate::commands::search_keywords("no.such.command").is_none());
}
/// With no document open, a document command says so instead of only `enabled: false` (#3664).
#[test]
fn command_list_tells_no_document_from_other_preconditions() {
@@ -1123,6 +1155,47 @@ fn coalesced_edits_share_one_history_step() {
assert!((l - 1.0).abs() < 1e-6, "{l}");
}
/// #2259: the commands behind the arrow-key nudges take the `coalesce` key, so a run of them is
/// one step an agent can drive too, and one Undo takes the whole run back.
#[test]
fn translate_and_transform_selection_coalesce_into_one_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 60})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let steps = |s: &Session| s.active().unwrap().history.past_len();
let left = |s: &Session| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().x0
};
let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds().x0;
let base = steps(&s);
for _ in 0..5 {
s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:1"})).unwrap();
}
assert_eq!((steps(&s), left(&s)), (base + 1, 15), "five nudges, one step");
s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap();
s.execute("layer.translate", json!({"dx": 1, "dy": 0})).unwrap();
s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap();
assert_eq!(steps(&s), base + 4, "another key, an uncoalesced move and a repeated key each start a step");
for _ in 0..3 {
assert!(s.undo());
}
assert_eq!(left(&s), 15);
assert!(s.undo());
assert_eq!(left(&s), 10, "Undo takes the run of five back at once");
let base = steps(&s);
for _ in 0..4 {
s.execute("select.transformSelection", json!({"dx": 1, "dy": 0, "coalesce": "nudge:3"})).unwrap();
}
assert_eq!((steps(&s), sel(&s)), (base + 1, 14));
assert!(s.undo());
assert_eq!(sel(&s), 10);
}
#[test]
fn type_edit_rerenders_cache_to_new_text() {
let mut s = Session::new();
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{EngineError, Result, Session};
/// Longest timeline, in frames: every frame becomes a surface in a new video layer and a file in
/// Render Video, so `duration` is capped well before it can exhaust memory or overflow (#1009).
const MAX_DURATION: usize = 1_000_000;
pub(crate) const MAX_DURATION: usize = 1_000_000;
/// Highest frame rate; larger values only come from mistakes and become `inf` as `f32` (#1009).
const MAX_FPS: f32 = 1000.0;
+154 -5
View File
@@ -36,6 +36,23 @@ pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
}
}
/// The box Free Transform opens on `layer`, and the Move tool's transform controls show (#5297).
/// With a selection that limits the transform it is the selection's own bounds, as in Photoshop:
/// the box and the marching ants are one rectangle, however little of the selection the layer
/// fills. Empty, like [`transform_bounds`], when the selection holds none of the layer.
pub fn transform_frame(doc: &Document, layer: &Layer) -> Rect {
let b = transform_bounds(doc, layer);
if moves_whole(layer) { b } else { framed_by_selection(doc, b) }
}
/// `bounds` (what a selection left of a transform's content), or the selection's own bounds.
fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
match &doc.selection {
Some(sel) if !bounds.is_empty() => sel.content_bounds(),
_ => bounds,
}
}
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
fn whole_layer_bounds(layer: &Layer) -> Rect {
let content = match &layer.content {
@@ -199,6 +216,12 @@ pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect {
}
}
/// The box Free Transform opens on a lone target: like [`transform_frame`], the selection's own
/// bounds when there is one (#5297).
pub fn target_frame(doc: &Document, surf: &Surface) -> Rect {
framed_by_selection(doc, target_bounds(doc, surf))
}
/// The locks `id` inherits from the groups around it.
pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
doc.path_of(id).and_then(|p| Some(doc.locks_at(p.split_last()?.1))).unwrap_or_default()
@@ -282,7 +305,12 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
}
_ => {
if let Some(surf) = l.surface() {
check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?;
// With a selection only the lifted pixels are warped (`split_selected`).
let src = match sel {
Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()),
None => surf.content_bounds(),
};
check_budget(src, h, &format!("layer \"{}\"", l.name))?;
}
}
}
@@ -383,17 +411,20 @@ fn apply_transform(
}
_ => {
if let Some(surf) = l.surface_mut() {
let src = surf.content_bounds();
*surf = match doc_sel {
// Only the selected pixels move: lift them, clear them, warp and paste back.
// Only the selected pixels move: lift them, clear them, warp and paste back. The
// lifted pixels' own bounds are the edge a stretch repeats (#3773).
Some(sel) => {
let (lifted, mut rest) = split_selected(surf, sel)?;
let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?;
let moved = warp_surface_with(&lifted, lifted.content_bounds(), h, interp, ctl)?.ok_or(EngineError::Cancelled)?;
composite_over(&mut rest, &moved);
rest.prune();
rest
}
None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?,
None => {
let src = surf.content_bounds();
warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?
}
};
}
}
@@ -445,6 +476,11 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface
let k = sel.sample_channel(x, y, 0);
l[n - 1] *= k;
r[n - 1] *= 1.0 - k;
// Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the
// selected pixels' (what a stretch repeats at the edge, #3773).
if l[n - 1] <= 0.0 {
l.fill(0.0);
}
if r[n - 1] <= 0.0 {
r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c);
}
@@ -1151,6 +1187,32 @@ mod tests {
assert_eq!(transform_bounds(&st.doc, st.doc.layer(st.active_layer.unwrap()).unwrap()), Rect::new(10, 10, 15, 20));
}
/// #5297: the box Free Transform opens with a selection is the selection's own rectangle,
/// not the part of it the layer fills; type still moves whole, and a selection that holds none
/// of the layer leaves nothing to transform.
#[test]
fn the_frame_is_the_selections_own_bounds() {
let mut s = session();
let frame = |s: &Session| {
let st = s.active().unwrap();
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
(transform_frame(&st.doc, l), transform_bounds(&st.doc, l))
};
let content = Rect::new(10, 10, 30, 20);
assert_eq!(frame(&s), (content, content), "no selection: the content");
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 15, "height": 40})).unwrap();
assert_eq!(frame(&s), (Rect::new(5, 5, 20, 45), Rect::new(10, 10, 20, 20)));
s.execute("select.rect", json!({"x": 60, "y": 60, "width": 10, "height": 10})).unwrap();
assert!(frame(&s).0.is_empty(), "nothing of the layer is selected");
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 120})).unwrap();
s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 36})).unwrap();
let whole = active_bounds(&s);
s.execute("select.rect", json!({"x": whole.x0, "y": whole.y0, "width": whole.width() / 2, "height": whole.height()})).unwrap();
assert_eq!(frame(&s), (whole, whole), "type moves whole");
}
#[test]
fn group_transform_ignores_selections_inside_its_children() {
group_transform_with_selection(Rect::new(10, 10, 15, 15));
@@ -1249,6 +1311,93 @@ mod tests {
assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform");
}
/// A 1 x 8 px column on a 256 x 256 canvas (#3773).
fn column_session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 256, "height": 256})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().fill_rect(Rect::new(100, 100, 101, 108), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s
}
fn opaque_green(s: &Session, x: i32, y: i32) -> bool {
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
(surf.sample_channel(x, y, 1) - 1.0).abs() < 1e-3 && (surf.sample_channel(x, y, 3) - 1.0).abs() < 1e-3
}
/// The "pixel stretch" (#3773): scales far past 1000% are taken, and the stretched column stays
/// solid along its whole length.
#[test]
fn a_one_pixel_column_stretches_5000_percent() {
let mut s = column_session();
s.execute("edit.transform", json!({"quad": [[100, 100], [150, 100], [150, 108], [100, 108]]})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(100, 100, 150, 108));
for x in [100, 101, 125, 149] {
for y in 100..108 {
assert!(opaque_green(&s, x, y), "({x}, {y})");
}
}
}
/// The technique as done in Photoshop: select a 1 px column of an image and stretch it across.
#[test]
fn a_selected_column_stretches_solid_over_the_image() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 256, "height": 256})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]);
surf.fill_rect(Rect::new(10, 0, 11, 256), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 256})).unwrap();
s.execute("edit.transform", json!({"quad": [[10, 0], [250, 0], [250, 256], [10, 256]]})).unwrap();
for x in [10, 11, 100, 200, 249] {
assert!(opaque_green(&s, x, 128), "x = {x}");
}
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
assert!(surf.sample_channel(250, 128, 0) > 0.999 && surf.sample_channel(9, 128, 0) > 0.999, "the image either side is untouched");
}
/// Free Transform keeps pixels moved off the canvas, as Photoshop does: an 8 x 8 layer moved
/// three canvas widths right and back comes back whole.
#[test]
fn a_layer_moved_far_off_the_canvas_and_back_keeps_its_pixels() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..8 {
for x in 0..8 {
surf.fill_rect(Rect::new(20 + x, 20 + y, 21 + x, 21 + y), &[x as f32 / 8.0, y as f32 / 8.0, 0.5, 1.0]);
}
}
Ok(())
})
.unwrap();
let pixels = |s: &Session| {
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
(0..8 * 8 * 4).map(|i| surf.sample_channel(20 + i / 4 % 8, 20 + i / 32, (i % 4) as usize)).collect::<Vec<_>>()
};
let before = pixels(&s);
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 192, 0]})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(212, 20, 220, 28));
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -192, 0]})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(20, 20, 28, 28));
assert_eq!(pixels(&s), before);
}
#[test]
fn relative_params_scale_and_rotate_about_the_frame_centre() {
let mut s = Session::new();
+18
View File
@@ -11,6 +11,10 @@ use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the
/// quantised frame and palette while rendering, so the document canvas limit is not enough.
const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20;
/// Set every video layer's displayed content to its frame at the playhead.
pub fn sync(doc: &mut Document) {
let cur = doc.timeline.as_ref().map_or(0, |t| t.current);
@@ -416,6 +420,13 @@ pub fn render_video_with(
let mut base = (*st.doc).clone();
store(&mut base);
let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1);
// Loaded timelines bypass the duration validation used by timeline.create/setProps.
if frames > crate::timeline_cmds::MAX_DURATION {
return Err(EngineError::Other(format!(
"the timeline is {frames} frames; Render Video is limited to {} frames",
crate::timeline_cmds::MAX_DURATION
)));
}
let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0);
let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string();
let fmt = base.pixel_format();
@@ -429,6 +440,13 @@ pub fn render_video_with(
// Animated GIF: one file, every frame quantised to its own palette.
if format.eq_ignore_ascii_case("gif") {
// The whole canvas is staged as f32 RGBA for each frame; reject over-budget
// dimensions before an infallible allocation can abort the process.
if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS {
return Err(EngineError::Other(format!(
"{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"
)));
}
let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16;
let mut gframes = Vec::with_capacity(frames);
for f in 0..frames {
+22
View File
@@ -498,3 +498,25 @@ fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() {
grow_duration(&mut t, 12);
assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12));
}
#[test]
fn render_video_rejects_a_loaded_timeline_past_the_frame_cap() {
let mut s = session();
let mut doc = (*s.active().unwrap().doc).clone();
doc.timeline = Some(Timeline::new(usize::MAX, 30.0));
{
let st = s.active_mut().unwrap();
st.doc = std::sync::Arc::new(doc);
st.revision += 1;
}
let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("overlong"), "format": "gif"})).unwrap_err();
assert!(err.to_string().contains("limited to"), "{err}");
}
#[test]
fn render_video_rejects_gif_frames_too_large_to_stage() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 9000, "height": 8000})).unwrap();
let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
+159
View File
@@ -0,0 +1,159 @@
//! Crop tool › Fill: Content-Aware Fill (#3823). A rotated crop, or one that extends past the
//! canvas, fills the area the image doesn't cover from its surroundings instead of the background
//! colour, in the crop's one undo step; an unrotated crop inside the canvas is unaffected.
use photocraft_engine::Session;
use serde_json::{Value, json};
const MODES: [(&str, u32); 5] = [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8)];
/// A 64 × 48 document whose Background is a light green, with black as the background colour (what
/// a plain crop puts in the uncovered area).
fn doc(mode: &str, depth: u32, background: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth, "background": background})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#c0e0a0", "background": "#000000"})).unwrap();
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
s
}
fn layer(s: &Session) -> &photocraft_raster::Surface {
let d = &s.active().unwrap().doc;
d.layers[0].surface().unwrap()
}
fn size(s: &Session) -> (u32, u32) {
let d = &s.active().unwrap().doc;
(d.size.width, d.size.height)
}
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < tol)
}
/// Every pixel of the cropped layer is the image's colour: no black (or transparent) area left.
fn assert_all_image(s: &Session, want: [f32; 4], what: &str) {
let (w, h) = size(s);
let surf = layer(s);
for y in 0..h as i32 {
for x in 0..w as i32 {
let px = surf.rgba(x, y);
assert!(close(px, want, 0.06), "{what}: ({x}, {y}) is {px:?}, want {want:?}");
}
}
}
fn crop(s: &mut Session, mut p: Value, content_aware: bool) -> photocraft_engine::Result<Value> {
p["contentAware"] = json!(content_aware);
s.execute("image.crop", p)
}
#[test]
fn rotated_crop_fills_the_corners_in_every_mode() {
for (mode, depth) in MODES {
let frame = json!({"x": 0, "y": 0, "width": 64, "height": 48, "angle": 10});
let mut plain = doc(mode, depth, "white");
let want = layer(&plain).rgba(32, 24);
crop(&mut plain, frame.clone(), false).unwrap();
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}: a plain rotated crop leaves black corners");
let mut s = doc(mode, depth, "white");
let past = s.active().unwrap().history.past_len();
crop(&mut s, frame, true).unwrap();
assert_eq!(size(&s), (64, 48), "{mode} {depth}");
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode} {depth}: one undo step");
assert_all_image(&s, want, &format!("{mode} {depth} rotated"));
}
}
#[test]
fn extending_crop_fills_the_added_canvas_in_every_mode() {
for (mode, depth) in MODES {
let frame = json!({"x": -8, "y": -6, "width": 80, "height": 60});
let mut plain = doc(mode, depth, "white");
let want = layer(&plain).rgba(32, 24);
crop(&mut plain, frame.clone(), false).unwrap();
assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}");
let mut s = doc(mode, depth, "white");
crop(&mut s, frame, true).unwrap();
assert_eq!(size(&s), (80, 60), "{mode} {depth}");
assert_all_image(&s, want, &format!("{mode} {depth} extended"));
}
}
/// Without a Background the active pixel layer is filled: no transparent strip is left.
#[test]
fn extending_crop_fills_the_active_layer_without_a_background() {
let mut s = doc("rgb", 16, "transparent");
let want = layer(&s).rgba(32, 24);
crop(&mut s, json!({"x": 0, "y": -10, "width": 64, "height": 58}), true).unwrap();
assert_all_image(&s, want, "transparent document");
}
/// Undo takes the crop and the fill back in one step.
#[test]
fn undo_restores_crop_and_fill_in_one_step() {
let mut s = doc("rgb", 8, "white");
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
crop(&mut s, json!({"x": -4, "y": -4, "width": 40, "height": 40, "angle": -6}), true).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(size(&s), (64, 48));
let after = layer(&s);
let orig = before.layers[0].surface().unwrap();
for y in 0..48 {
for x in 0..64 {
assert_eq!(after.rgba(x, y), orig.rgba(x, y), "({x}, {y})");
}
}
}
/// `contentAware: false` (or absent), and a crop that uncovers nothing, are the plain crop.
#[test]
fn nothing_uncovered_or_off_is_the_plain_crop() {
for (frame, ca) in [
(json!({"x": 5, "y": 4, "width": 30, "height": 20}), true),
(json!({"x": -5, "y": -4, "width": 80, "height": 60}), false),
(json!({"x": 20, "y": 15, "width": 20, "height": 14, "angle": 4}), true),
] {
let mut a = doc("rgb", 16, "white");
let mut b = doc("rgb", 16, "white");
a.execute("image.crop", frame.clone()).unwrap();
crop(&mut b, frame.clone(), ca).unwrap();
assert_eq!(size(&a), size(&b), "{frame}");
let (w, h) = size(&a);
for y in 0..h as i32 {
for x in 0..w as i32 {
assert_eq!(layer(&a).rgba(x, y), layer(&b).rgba(x, y), "{frame} ({x}, {y})");
}
}
}
}
/// Bad params and an unfillable crop fail without changing the document.
#[test]
fn bad_params_and_unfillable_crops_fail_cleanly() {
let frame = json!({"x": -4, "y": 0, "width": 70, "height": 48});
for v in [json!("yes"), json!(1), json!([true]), json!({"on": true})] {
let mut s = doc("rgb", 8, "white");
let past = s.active().unwrap().history.past_len();
let mut p = frame.clone();
p["contentAware"] = v.clone();
assert!(s.execute("image.crop", p).is_err(), "{v}");
assert_eq!(size(&s), (64, 48), "{v}");
assert_eq!(s.active().unwrap().history.past_len(), past, "{v}");
}
// Entirely off the canvas: nothing of the image to sample from (both paths).
for p in [json!({"x": 500, "y": 500, "width": 20, "height": 20}), json!({"x": 500, "y": 500, "width": 20, "height": 20, "angle": 30})] {
let mut s = doc("rgb", 8, "white");
let past = s.active().unwrap().history.past_len();
let err = crop(&mut s, p.clone(), true).unwrap_err();
assert!(err.to_string().contains("sample"), "{p}: {err}");
assert_eq!(size(&s), (64, 48), "{p}");
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Variable definitions must continue targeting their layers after a copied document gets
//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers.
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer};
use photocraft_engine::Session;
use serde_json::json;
fn scene() -> (Session, LayerId, LayerId, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let ids = s
.edit("setup", |doc, _| {
let fmt = doc.pixel_format();
let mut photo = Layer::raster("photo", fmt);
photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0]));
let badge = Layer::raster("badge", fmt);
let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() }));
let (p, b, t) = (photo.id, badge.id, title.id);
doc.layers.push(photo);
doc.layers.push(badge);
doc.layers.push(title);
Ok((p, b, t))
})
.unwrap();
(s, ids.0, ids.1, ids.2)
}
fn red_png(dir: &str) -> String {
let doc = Document::with_background(
"red",
Size::new(4, 4),
photocraft_doc::ColorMode::Rgb,
photocraft_doc::SampleType::U8,
photocraft_doc::Color::rgb(1.0, 0.0, 0.0),
);
let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes;
let path = format!("{dir}/replacement.png");
std::fs::write(&path, bytes).unwrap();
path
}
fn copied_layer(s: &Session, name: &str) -> LayerId {
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id
}
fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) {
s.execute(
"image.variables.define",
json!({"defs": [
{"name": "showBadge", "layer": badge.0, "type": "visibility"},
{"name": "headline", "layer": title.0, "type": "textReplacement"},
{"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"},
]}),
)
.unwrap();
s.execute(
"image.variables.dataSets",
json!({"dataSets": [
{"name": "A", "values": [
{"variable": "showBadge", "kind": "visibility", "value": false},
{"variable": "headline", "kind": "text", "value": "Hello"},
{"variable": "photo", "kind": "pixels", "value": png},
]},
]}),
)
.unwrap();
}
#[test]
fn duplicated_documents_keep_variables_targeting_the_copied_layers() {
let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = red_png(&dir.to_string_lossy());
for command in ["image.duplicate", "history.newDocument"] {
let (mut s, photo, badge, title) = scene();
define_all(&mut s, photo, badge, title, &png);
s.execute(command, json!({})).unwrap();
let doc = &s.active().unwrap().doc;
let new_photo = copied_layer(&s, "photo");
let new_badge = copied_layer(&s, "badge");
let new_title = copied_layer(&s, "title");
assert_ne!(new_photo, photo, "{command}: layers must get new identities");
let targets: Vec<LayerId> = doc.variables.defs.iter().map(|d| d.layer).collect();
assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers");
let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap();
assert_eq!(r["applied"], "A");
let doc = &s.active().unwrap().doc;
assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy");
match &doc.layer(new_title).unwrap().content {
LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"),
_ => panic!("not a text layer"),
}
let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0);
assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}");
// The source document still targets its own layers and is untouched.
s.set_active(0);
let src = &s.active().unwrap().doc;
let src_targets: Vec<LayerId> = src.variables.defs.iter().map(|d| d.layer).collect();
assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched");
assert!(src.layer(badge).unwrap().visible);
assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9);
}
}
#[test]
fn dangling_variable_targets_stay_dangling_after_duplication() {
let (mut s, photo, badge, title) = scene();
define_all(&mut s, photo, badge, title, "unused.png");
// A def may already refer to a deleted layer before duplication.
let missing = LayerId(u64::MAX);
s.edit("Break one def", |doc, _| {
doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing;
Ok(())
})
.unwrap();
s.execute("image.duplicate", json!({})).unwrap();
let doc = &s.active().unwrap().doc;
let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap();
assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id");
let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap();
assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap");
}
#[test]
fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() {
let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = red_png(&dir.to_string_lossy());
let (mut s, photo, badge, title) = scene();
define_all(&mut s, photo, badge, title, &png);
s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer");
assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers");
assert!(d.variables.data_sets.is_empty());
assert_eq!(d.variables.active, None);
s.set_active(0);
assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved");
}
+102 -1
View File
@@ -4,7 +4,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject};
use photocraft_engine::Session;
use photocraft_engine::smart_cmds::{decode_source, source_bytes};
use photocraft_geom::Rect;
use serde_json::json;
use serde_json::{Value, json};
fn session(depth: u64) -> (Session, u64, u64) {
let mut s = Session::new();
@@ -195,3 +195,104 @@ fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() {
s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]);
}
/// A `.pcraft` file whose contents are one solid colour, for Replace Contents.
fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
paint(&mut s, rgba);
let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id()));
std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap();
path
}
/// The node named `name` in `document.inspect`'s layer tree, searched through groups.
fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> {
layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) })
}
/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so
/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which
/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count,
/// and Replace Contents lists every layer it changed.
#[test]
fn inspect_and_replace_contents_report_shared_instances() {
let (mut s, original, duplicate) = session(8);
let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap();
s.edit("name and group", |doc, _| {
for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] {
doc.layer_mut(LayerId(id)).unwrap().name = name.into();
}
// An instance inside a group counts too.
let copy = doc.remove(LayerId(duplicate)).unwrap();
doc.layers.push(Layer::group("Copies", vec![copy]));
Ok(())
})
.unwrap();
let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap();
let mut s = Session::new();
s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None);
let tree = s.execute("document.inspect", json!({})).unwrap();
let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone());
assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]);
assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]);
assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]);
assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers");
let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap());
let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances");
let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path}));
std::fs::remove_file(path).unwrap();
let mut changed: Vec<u64> = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
changed.sort_unstable();
assert_eq!(changed, if o < d { [o, d] } else { [d, o] });
assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]);
for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] {
assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents");
}
}
/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object
/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents.
#[cfg(feature = "corpus")]
#[test]
fn photoshop_placed_copies_import_as_shared_instances() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd");
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone();
file.layers_mut().push(placed);
let mut s = Session::new();
s.add_document(photocraft_io::psd_to_document(&file).0, None);
let tree = s.execute("document.inspect", json!({})).unwrap();
let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect();
assert_eq!(copies.len(), 2);
assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]);
assert_eq!(copies[0]["smartContents"]["instances"], 2);
for copy in &copies {
assert_eq!(copy["smartSource"]["kind"], "embedded");
assert!(copy["smartSource"]["uuid"].as_str().is_some());
}
let mut ids: Vec<u64> = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect();
let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies");
let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path}));
std::fs::remove_file(path).unwrap();
let mut changed: Vec<u64> = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
changed.sort_unstable();
ids.sort_unstable();
assert_eq!(changed, ids);
for id in ids {
assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]);
let cache = smart(&s, id).cache.as_ref().unwrap();
let bounds = cache.content_bounds();
assert!(!bounds.is_empty(), "instance {id} must have rendered pixels");
assert_eq!(
cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2),
[0.0, 1.0, 0.0, 1.0],
"instance {id} must refresh its imported cache"
);
}
}
+10 -1
View File
@@ -617,6 +617,15 @@ impl Loader<'_> {
}
}
}
// Variable targets follow their layers through id remapping; a dangling one keeps a
// non-colliding placeholder so it stays a missing-layer skip rather than driving an
// unrelated layer (#4969). With preserved ids the map is the identity and defs stay as saved.
let mut variables = m.variables.clone();
if !self.preserve_ids {
for vd in &mut variables.defs {
vd.layer = self.id_map.get(&vd.layer.0).copied().unwrap_or(LayerId(u64::MAX - vd.layer.0.min(u64::MAX / 2)));
}
}
let id = if self.preserve_ids { DocId(m.id) } else { DocId::fresh() };
Ok(Document {
id,
@@ -641,7 +650,7 @@ impl Loader<'_> {
patterns,
color_table: m.color_table.clone(),
duotone: m.duotone.clone(),
variables: m.variables.clone(),
variables,
timeline: m.timeline.clone(),
file_encoding: m.file_encoding,
layer_comps,
+23 -1
View File
@@ -5,7 +5,7 @@ use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun};
use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef};
use photocraft_format::*;
use photocraft_raster::Rgba8Image;
@@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load()
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
assert_eq!(back, doc);
}
#[test]
fn variables_follow_layer_id_remapping() {
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id;
doc.variables.defs = vec![
VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement },
VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility },
VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility },
];
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
// Preserved ids keep every definition exactly as saved, dangling ones included.
assert_eq!(load_from_bytes(&bytes).unwrap(), doc);
// Remapped ids carry the definitions to the copied layers; a dangling target stays
// dangling and must not collide with a live layer (#4969).
let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap();
let defs = &back.variables.defs;
assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint"));
assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner"));
let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer;
assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer");
}
+28 -2
View File
@@ -46,7 +46,12 @@ impl Rect {
Self { x0, y0, x1, y1 }
}
pub fn from_xywh(x: i32, y: i32, w: u32, h: u32) -> Self {
Self::new(x, y, x.saturating_add(w as i32), y.saturating_add(h as i32))
// Widen before adding: `w as i32` wraps negative for widths above `i32::MAX`, which
// would turn the far edge backwards (an empty, inverted rect) instead of clamping it
// to `i32::MAX` the way `saturating_add` alone would suggest.
let x1 = (i64::from(x) + i64::from(w)).min(i64::from(i32::MAX)) as i32;
let y1 = (i64::from(y) + i64::from(h)).min(i64::from(i32::MAX)) as i32;
Self::new(x, y, x1, y1)
}
pub fn from_size(size: Size) -> Self {
Self::from_xywh(0, 0, size.width, size.height)
@@ -117,7 +122,9 @@ impl TileCoord {
Self::new(x.div_euclid(TILE_SIZE), y.div_euclid(TILE_SIZE))
}
pub fn rect(&self) -> Rect {
Rect::from_xywh(self.tx * TILE_SIZE, self.ty * TILE_SIZE, TILE_SIZE as u32, TILE_SIZE as u32)
// `tx`/`ty` can come from a file (this type is deserialized), so saturate instead of
// overflowing (a debug-build panic / release-build wrap) on extreme coordinates.
Rect::from_xywh(self.tx.saturating_mul(TILE_SIZE), self.ty.saturating_mul(TILE_SIZE), TILE_SIZE as u32, TILE_SIZE as u32)
}
}
@@ -258,6 +265,25 @@ mod tests {
assert_eq!(grown.y0, i32::MIN);
}
#[test]
fn from_xywh_clamps_huge_sizes_instead_of_wrapping() {
// Widths above `i32::MAX` used to wrap negative through `w as i32`, producing an
// empty inverted rect instead of clamping the far edge to `i32::MAX`.
assert_eq!(Rect::from_xywh(0, 0, u32::MAX, 10), Rect::new(0, 0, i32::MAX, 10));
assert_eq!(Rect::from_xywh(-100, 5, u32::MAX, u32::MAX), Rect::new(-100, 5, i32::MAX, i32::MAX));
assert_eq!(Rect::from_xywh(10, 20, i32::MAX as u32 + 1, 40).width(), i32::MAX as u32 - 10);
// Ordinary sizes are unchanged.
assert_eq!(Rect::from_xywh(10, 20, 30, 40), Rect::new(10, 20, 40, 60));
}
#[test]
fn tile_rect_saturates_on_extreme_coords() {
// Tile coords are deserialized from files; `tx * TILE_SIZE` used to overflow.
let r = TileCoord::new(i32::MAX, i32::MIN).rect();
assert_eq!((r.x0, r.y0), (i32::MAX, i32::MIN));
assert_eq!(TileCoord::new(2, -3).rect(), Rect::new(512, -768, 768, -512));
}
#[test]
fn width_and_height_do_not_overflow_on_extreme_edges() {
// Inverted extreme rect: empty, so 0 (x1 - x0 used to overflow and panic).
+42 -38
View File
@@ -473,6 +473,17 @@ fn posterize(v: f32, n: f32) -> f32 {
return floor(bin * 255.0 / (n - 1.0)) / 255.0;
}
// compose::adjust::posterize_in at 16 bits: bins of the 16-bit sample, landing on 8-bit levels
// through a 16-bit step. Every product and sum here is a whole number below 2^24, exact in f32;
// 16-bit layers reach the GPU as half floats, though, so a sample within their precision of a
// bin's edge can land in the neighbouring bin (the CPU, which exports, is exact).
fn posterize16(v: f32, n: f32) -> f32 {
let x = floor(clamp(v, 0.0, 1.0) * 32768.0 + 0.5);
let bin = min(floor(x * n / 32768.0), n - 1.0);
let step = floor(bin * 32768.0 / (n - 1.0));
return floor((step * 255.0 + 16384.0) / 32768.0) / 255.0;
}
// Document pixel being adjusted (for ordered dither).
var<private> adj_px: vec2<i32>;
@@ -580,49 +591,37 @@ fn tone_channel(row: i32, v: f32, identity: f32) -> f32 {
return lut(row, v);
}
// Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor.
// Mirrors compose::adjust::modern_brightness.
fn mbright(v: f32, b: f32) -> f32 {
if (b == 0.0) { return v; }
let s = pow(1.375, b / 50.0);
var p: f32;
if (b >= 0.0) { p = max(4.5 - 0.013 * b, 2.0); } else { p = 5.0 - 0.072 * b; }
let vv = clamp(v, 0.0, 1.0);
return clamp(s * vv + (1.0 - s) * pow(vv, p), 0.0, 1.0);
}
// Modern Contrast curve: symmetric cubic-Hermite S pivoting at 0.5.
// Mirrors compose::adjust::modern_contrast.
fn mcontrast(v: f32, ct: f32) -> f32 {
if (ct == 0.0) { return v; }
let k = ct / 128.0;
let e = 1.0 - k; let m = 1.0 + k;
let vv = clamp(v, 0.0, 1.0);
var t: f32;
if (vv <= 0.5) { t = vv / 0.5; } else { t = (1.0 - vv) / 0.5; }
let h = e * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + m * 0.5 * (t * t * t - t * t);
if (vv <= 0.5) { return h; }
return 1.0 - h;
}
fn adjust(c: vec3<f32>) -> vec3<f32> {
let hdr = (op.flags & F_HDR) != 0u;
let p0 = op.p0;
let p1 = op.p1;
let p2 = op.p2;
let p3 = op.p3;
switch op.kind {
case 1: { return 1.0 - c; } // Invert
case 2: { // Threshold (p0.x = 8-bit level)
return vec3(select(0.0, 1.0, round_half_up(gray(c) * 255.0) >= p0.x));
case 2: { // Threshold (compose::adjust::threshold_white)
// p0.x = 8-bit level; p0.y = 1: 8-bit levels, 2: 16-bit samples against p0.z, 0: float.
// Sums of 30 R + 59 G + 11 B over whole samples are exact in f32.
var white = lum(c) * 255.0 >= p0.x;
if p0.y > 0.5 {
let q = select(32768.0, 255.0, p0.y < 1.5);
let v = floor(clamp(c, vec3(0.0), vec3(1.0)) * q + 0.5);
let s = 30.0 * v.r + 59.0 * v.g + 11.0 * v.b;
white = select(s >= 100.0 * p0.z, s > 100.0 * p0.x - 50.0, p0.y < 1.5);
}
return vec3(select(0.0, 1.0, white));
}
case 3: { return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); }
case 4: { return clamp((c + p0.x - p0.z) * p0.y + p0.z + p0.w, vec3(0.0), vec3(1.0)); } // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around compose::adjust::LEGACY_PIVOT (p0.z)
case 5: { // B/C modern
let b = p0.x; let ct = p0.y;
return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct));
case 3: { // Posterize (p0.x levels; p0.y = 1: 16-bit)
if p0.y > 0.5 { return vec3(posterize16(c.r, p0.x), posterize16(c.g, p0.x), posterize16(c.b, p0.x)); }
return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x));
}
case 4: { // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around the pivot (p0.z); 32-bit unclipped
let v = (c + p0.x - p0.z) * p0.y + p0.z + p0.w;
if (hdr) { return v; }
return clamp(v, vec3(0.0), vec3(1.0));
}
case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure
case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves
case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut)
case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }
case 8: { // Hue/Saturation
let hsl = rgb_to_hsl(c);
@@ -678,13 +677,17 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
l = clamp(l, vec3(0.0), vec3(p0.w));
return vec3(t_encode(l.r, p0.z), t_encode(l.g, p0.z), t_encode(l.b, p0.z));
}
case 10: { // Channel mixer
let r = clamp(dot(p0.xyz, c) + p0.w, 0.0, 1.0);
case 10: { // Channel mixer (32-bit unclipped)
let lo = select(0.0, -F32_MAX, hdr);
let hi = select(1.0, F32_MAX, hdr);
let r = clamp(dot(p0.xyz, c) + p0.w, lo, hi);
if (p3.x > 0.5) { return vec3(r); }
return vec3(r, clamp(dot(p1.xyz, c) + p1.w, 0.0, 1.0), clamp(dot(p2.xyz, c) + p2.w, 0.0, 1.0));
return vec3(r, clamp(dot(p1.xyz, c) + p1.w, lo, hi), clamp(dot(p2.xyz, c) + p2.w, lo, hi));
}
case 11: { // Photo filter
// compose::adjust::photo_filter_matrix (rows p0..p2) in linear light, then SetLum.
// 32-bit samples are linear already and stay unclipped.
if (hdr) { return vec3(dot(p0.xyz, c), dot(p1.xyz, c), dot(p2.xyz, c)); }
let g = p3.y;
let lin = vec3(t_decode(c.r, g), t_decode(c.g, g), t_decode(c.b, g));
var f = vec3(t_encode(dot(p0.xyz, lin), g), t_encode(dot(p1.xyz, lin), g), t_encode(dot(p2.xyz, lin), g));
@@ -703,12 +706,13 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; }
let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0);
if (p1.z > 0.5) {
if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1
return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint
}
return vec3(g);
}
case 13: { // Gradient map
var t = gray(c);
case 13: { // Gradient map (driven by the W3C luminosity)
var t = lum(c);
if (p0.x > 0.5) { t = 1.0 - t; }
var o = vec3(lut(0, t), lut(1, t), lut(2, t));
if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); }
+90 -13
View File
@@ -24,7 +24,9 @@
//! staged uploads and evicted pages stay bounded.
//! - **Stack reuse.** A complete root stack unit can checkpoint its backdrop into bounded chunk
//! textures. Exact COW snapshots invalidate the lower prefix; edits above it restore those
//! pixels and execute only the remaining passes. Groups and clipping units stay indivisible.
//! pixels and execute only the remaining passes, capturing on the way the highest checkpoint
//! below the edit, so repeating it (a duplicate or paste and its undo) skips that much more.
//! Groups and clipping units stay indivisible.
//!
//! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to
//! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape
@@ -75,6 +77,11 @@ pub const FX_BUDGET: usize = 1536 << 20;
pub const PAGE: u32 = 2048;
/// GPU memory resident layer pages may hold before the least recently used are evicted.
pub const RESIDENT_BUDGET: u64 = 4 << 30;
/// GPU memory pattern textures may hold before the least recently painted are dropped (those the
/// current frame paints with always stay).
pub const PATTERN_BUDGET: u64 = 256 << 20;
/// A pattern texture no frame has painted with for this many frames is dropped.
pub const PATTERN_IDLE_FRAMES: u64 = 240;
/// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so
/// a refresh of a huge document never holds all its uploads in staging memory at once.
const FLUSH_BYTES: u64 = 512 << 20;
@@ -183,6 +190,12 @@ struct CachedPattern {
format: PixelFormat,
default_bits: Vec<u32>,
texture: Tex,
/// The document that last painted with it: closing that document frees the texture (#4971).
doc: DocId,
/// [`Compositor::frame`] when it last painted.
last_used: u64,
/// GPU bytes of the texture (RGBA32F).
bytes: u64,
}
struct Resident {
@@ -423,6 +436,10 @@ pub struct Compositor {
/// The colour mode of the document being encoded: patterns paint in it
/// (`photocraft_compose::pattern::pattern_rgba`).
pattern_mode: photocraft_color::ColorMode,
/// The document being encoded, which owns the patterns it paints with.
pattern_doc: DocId,
/// GPU memory pattern textures may hold ([`PATTERN_BUDGET`] by default).
pattern_budget: u64,
/// Texture side limit the paging is planned for (the device's, or smaller in tests).
max_dim: u32,
/// The device's real texture side limit.
@@ -670,6 +687,8 @@ impl Compositor {
temps: Vec::new(),
patterns: HashMap::new(),
pattern_mode: photocraft_color::ColorMode::Rgb,
pattern_doc: DocId(0),
pattern_budget: PATTERN_BUDGET,
max_dim: 0,
device_max: device.limits().max_texture_dimension_2d,
page: PAGE,
@@ -791,6 +810,17 @@ impl Compositor {
self.residents.values().map(Resident::bytes).sum()
}
/// GPU memory held by cached pattern textures (bytes).
pub fn pattern_cache_bytes(&self) -> u64 {
self.patterns.values().map(|p| p.bytes).sum()
}
/// Set the GPU memory pattern textures may hold (default [`PATTERN_BUDGET`]). It takes effect
/// at the end of the next refresh.
pub fn set_pattern_budget(&mut self, bytes: u64) {
self.pattern_budget = bytes;
}
/// Whether `doc` can be composited on the GPU. Documents and layers larger than the texture
/// limit are fine: surfaces are stored in page-sized textures and effect maps of regions
/// larger than the limit are built per page cell.
@@ -865,14 +895,22 @@ impl Compositor {
pub fn forget_doc(&mut self, doc: DocId) {
self.residents.retain(|_, r| r.doc != doc);
self.fx.retain(|_, e| e.doc != doc);
self.patterns.retain(|_, p| p.doc != doc);
self.prefix.forget(doc);
}
/// [`Self::forget_doc`] for every document `keep` rejects: what the compositor holds for
/// documents that were closed, however their canvas entry went (#4529).
pub fn retain_docs(&mut self, keep: impl Fn(DocId) -> bool) {
let mut gone: Vec<DocId> =
self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).filter(|d| !keep(*d)).collect();
let mut gone: Vec<DocId> = self
.residents
.values()
.map(|r| r.doc)
.chain(self.fx.values().map(|e| e.doc))
.chain(self.patterns.values().map(|p| p.doc))
.chain(self.prefix.doc())
.filter(|d| !keep(*d))
.collect();
gone.sort_by_key(|d| d.0);
gone.dedup();
for d in gone {
@@ -880,9 +918,17 @@ impl Compositor {
}
}
/// Documents the compositor holds pages, effect maps or a lower-stack composite for.
/// Documents the compositor holds pages, effect maps, pattern textures or a lower-stack
/// composite for.
pub fn held_docs(&self) -> Vec<DocId> {
let mut docs: Vec<DocId> = self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).collect();
let mut docs: Vec<DocId> = self
.residents
.values()
.map(|r| r.doc)
.chain(self.fx.values().map(|e| e.doc))
.chain(self.patterns.values().map(|p| p.doc))
.chain(self.prefix.doc())
.collect();
docs.sort_by_key(|d| d.0);
docs.dedup();
docs
@@ -994,6 +1040,7 @@ impl Compositor {
let plan = plan(doc)?;
self.check_fx(doc, &plan)?;
self.pattern_mode = doc.pixel_format().mode;
self.pattern_doc = doc.id;
let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() };
if region.is_empty() {
return Ok(stats);
@@ -1093,9 +1140,31 @@ impl Compositor {
self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id));
stats.evicted += self.evict().0;
self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240);
self.evict_patterns();
Ok(stats)
}
/// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames,
/// then the least recently painted ones over the pattern budget. Never one this frame painted
/// with: it would only be uploaded again on the next frame.
fn evict_patterns(&mut self) {
let frame = self.frame;
self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES);
let mut total = self.pattern_cache_bytes();
if total > self.pattern_budget {
let mut lru: Vec<(u64, String, u64)> =
self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect();
lru.sort_unstable();
for (_, key, bytes) in lru {
if total <= self.pattern_budget {
break;
}
self.patterns.remove(&key);
total = total.saturating_sub(bytes);
}
}
}
/// Keep resident pages within the resident budget and the effect cache within
/// its budget, least recently used first. Never drops what the current cell uses, nor
/// whole-region effect maps drawn this frame. Returns how many textures were dropped and
@@ -1235,7 +1304,10 @@ impl Compositor {
if chunks.is_empty() {
return;
}
let first_pass = checkpoint.filter(|_| chunks.iter().all(|c| self.prefix.contains(*c))).map_or(0, |c| c.pass_end);
// Chunks may hold different checkpoints: bind from the lowest one any of them resumes at.
let first_pass = checkpoint
.and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?))))
.unwrap_or(0);
let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats);
// Chunk pool.
@@ -1305,7 +1377,7 @@ impl Compositor {
for (ci, c) in chunks.iter().enumerate() {
let mut resume = 0;
if let Some(cp) = checkpoint
if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c))
&& let Some((dst, _)) = self.pool.get(cp.slot as usize)
&& self.prefix.copy_out(encoder, *c, dst)
{
@@ -1330,11 +1402,12 @@ impl Compositor {
return;
}
}
if resume == 0
// Also after resuming from a lower checkpoint: the chunk moves up to this one.
if resume < i
&& let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i)
&& let Some((src, _)) = self.pool.get(cp.slot as usize)
{
self.prefix.capture(device, encoder, *c, src);
self.prefix.capture(device, encoder, *c, src, cp);
}
let scissor = match p.clip {
Some(clip) => {
@@ -1400,11 +1473,11 @@ impl Compositor {
pass.set_bind_group(1, bg1[i].as_ref(), &[]);
pass.draw(0..3, 0..1);
}
if resume == 0
if resume < plan.passes.len()
&& let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len())
&& let Some((src, _)) = self.pool.get(cp.slot as usize)
{
self.prefix.capture(device, encoder, *c, src);
self.prefix.capture(device, encoder, *c, src, cp);
}
let (t, v) = &self.pool[plan.root as usize];
sink(encoder, ChunkOut { rect: *c, texture: t, view: v });
@@ -1579,11 +1652,14 @@ impl Compositor {
let format = pat.surface.format();
// Unstored tiles read the default pixel, interpreted in the source format.
let default_bits: Vec<u32> = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect();
if let Some(cached) = self.patterns.get(&key)
let (doc, last_used) = (self.pattern_doc, self.frame);
if let Some(cached) = self.patterns.get_mut(&key)
&& cached.fingerprint == fp
&& cached.format == format
&& cached.default_bits == default_bits
{
cached.doc = doc;
cached.last_used = last_used;
return cached.texture.view.clone();
}
let (w, h) = (pat.width, pat.height);
@@ -1597,7 +1673,8 @@ impl Compositor {
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let v = t.view.clone();
self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t });
let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16;
self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes });
v
}
+36 -12
View File
@@ -652,11 +652,15 @@ impl<'a> Planner<'a> {
let opacity = layer.opacity * layer.fill_opacity;
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does).
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does), and
// so does one that is the base of a clipping group and holds an adjustment layer.
let clip_base_adjusts = !visible_clipped.is_empty()
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
if let LayerContent::Group(g) = &layer.content
&& layer.blend == BlendMode::PassThrough
&& layer.fill_opacity >= 1.0
&& !has_effects(layer)
&& !clip_base_adjusts
{
let before = self.retain(backdrop);
let mut after = self.stack(&g.children, backdrop)?;
@@ -1593,11 +1597,19 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
match adj {
Adjustment::Invert => (1, p, None),
Adjustment::Threshold { level } => {
p[0][0] = (level * 255.0).round();
// The level, the depth's split (compose::adjust::threshold_white) and the level in 16 bits.
let t = (level * 255.0).round();
p[0] = match depth {
photocraft_color::SampleType::U8 => [t, 1.0, 0.0, 0.0],
photocraft_color::SampleType::U16 => [t, 2.0, (t * 32768.0 / 255.0).round(), 0.0],
photocraft_color::SampleType::F32 => [t, 0.0, 0.0, 0.0],
};
(2, p, None)
}
Adjustment::Posterize { levels } => {
// The levels, and the 16-bit rule (compose::adjust::posterize_in).
p[0][0] = (*levels).clamp(2, 255) as f32;
p[0][1] = if depth == photocraft_color::SampleType::U16 { 1.0 } else { 0.0 };
(3, p, None)
}
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
@@ -1605,14 +1617,16 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
// Brightness before contrast when it is raised, after it when lowered (compose::adjust).
let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) };
p[0] = [pre, k, photocraft_compose::adjust::LEGACY_PIVOT, post];
// 32-bit documents pivot at 127 of 255 and don't clip (the shader checks F_HDR).
let pivot = if depth == photocraft_color::SampleType::F32 { 127.0 / 255.0 } else { photocraft_compose::adjust::LEGACY_PIVOT };
p[0] = [pre, k, pivot, post];
(4, p, None)
}
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
// Modern B/C: the shader rebuilds the curves from raw slider values (see compose.wgsl
// `mbright`/`mcontrast`, mirroring compose::adjust::modern_brightness/modern_contrast).
p[0] = [*brightness, *contrast, 0.0, 0.0];
(5, p, None)
// Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel,
// through the Levels / Curves kernel.
let row = to_row(&adjust::modern_lut(*brightness, *contrast));
(7, p, Some(vec![row; 3]))
}
Adjustment::Exposure { exposure, offset, gamma } => {
p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())];
@@ -1655,9 +1669,14 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
(9, p, None)
}
Adjustment::ChannelMixer { matrix, monochrome } => {
p[0] = matrix[0];
p[1] = matrix[1];
p[2] = matrix[2];
// compose::adjust: 32-bit documents cut each percentage to 1/1024, count 255/512 of
// the constant and don't clip (the shader checks F_HDR).
let hdr = depth == photocraft_color::SampleType::F32;
let cut = |v: f32| if hdr { (v * 1024.0).trunc() / 1024.0 } else { v };
let rows = matrix.map(|r| [cut(r[0]), cut(r[1]), cut(r[2]), if hdr { r[3] * 255.0 / 512.0 } else { r[3] }]);
p[0] = rows[0];
p[1] = rows[1];
p[2] = rows[2];
p[3][0] = if *monochrome { 1.0 } else { 0.0 };
(10, p, None)
}
@@ -1813,10 +1832,15 @@ mod tests {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}");
}
// CMYK documents invert, posterize and threshold their inks, on the CPU.
let inks = matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. });
// CMYK documents invert, posterize, threshold and brighten their inks, on the CPU.
let inks = !matches!(adj, Adjustment::GradientMap { .. });
assert_eq!(adjustment_on_gpu(adj, ColorMode::Cmyk), !inks, "{adj:?} in CMYK");
}
// CMYK Hue/Saturation works on the inks (on the CPU); Colorize stays on the screen colour.
let ranges = photocraft_doc::adjust::HueRange::defaults();
let hue = |colorize| Adjustment::HueSaturation { hue: 30.0, saturation: 10.0, lightness: 0.0, colorize, ranges: ranges.clone() };
assert!(!adjustment_on_gpu(&hue(false), ColorMode::Cmyk) && adjustment_on_gpu(&hue(true), ColorMode::Cmyk));
assert!(adjustment_on_gpu(&hue(false), ColorMode::Rgb));
// Screen-colour adjustments stay on the GPU in Lab documents.
assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab));
}

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