mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 08:00:02 +00:00
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:
+4
-2
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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, ¶ms), 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user