mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 01:12:03 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/ui-egui/src/tools/brush_picker.rs
This commit is contained in:
commit
f1702f99bc
392 files changed
+25290
-1381
No files matched your search
@@ -162,7 +162,7 @@ jobs:
|
||||
PHOTOCRAFT_NO_PRINT=1
|
||||
LPDEST=photocraft-no-such-printer
|
||||
timeout-minutes: 100
|
||||
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
|
||||
# fmt, clippy, layers, panel-commands, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
|
||||
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
|
||||
command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower
|
||||
cargo test -p <crates you touched>
|
||||
cargo clippy -p <crates> --all-targets -- -D warnings
|
||||
cargo xtask layers
|
||||
cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1)
|
||||
cargo xtask wasm # if you touched L0–L6
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md
|
||||
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
|
||||
|
||||
+15
-1
@@ -42,7 +42,7 @@ next to them.
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 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/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -58,6 +58,7 @@ next to them.
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/vertical-align-{top,middle,bottom}.svg` | Type options-bar alignment glyphs for vertical text: three columns hanging from a top line, centred on a middle line, or standing on a bottom line, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -166,3 +167,16 @@ are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in
|
||||
`897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS
|
||||
extension and current-main integration build on that source. Clipboard and unrelated
|
||||
Shape Tools changes from that branch are not included.
|
||||
|
||||
## Assets bundled by the PDF reader dependency (craft-pdf)
|
||||
|
||||
PhotoCraft imports PDF pages through `craft-pdf`, pinned to the craft-libs revision
|
||||
`c5a1cbfb5f9049f9270955caffb5bee5997aebf7`. The renderer vendors Hayro and the assets below;
|
||||
they ship inside the dependency rather than being copied into this repository. PDFium is no
|
||||
longer used or shipped.
|
||||
|
||||
| Crate / file | Author | Source | Licence | Use |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `hayro-interpret 0.7.0 / assets/Foxit*.pfb` (13 standard-14 font files) | Foxit Software Inc. / PDFium Authors | <https://github.com/storytold/craft-libs/tree/c5a1cbfb5f9049f9270955caffb5bee5997aebf7/vendor/hayro-interpret/assets> | BSD-3-Clause | Renders non-embedded standard-14 PDF fonts |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | <https://github.com/storytold/craft-libs/tree/c5a1cbfb5f9049f9270955caffb5bee5997aebf7/vendor/hayro-interpret/assets> | CC0-1.0 | Default CMYK colour conversion |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | <https://github.com/storytold/craft-libs/tree/c5a1cbfb5f9049f9270955caffb5bee5997aebf7/vendor/hayro-cmap/assets> | BSD-3-Clause | Character-code mapping for CJK PDFs |
|
||||
Generated
+300
-20
@@ -22,7 +22,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.14.0",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -826,6 +826,17 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
@@ -1443,6 +1454,18 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1472,7 +1495,7 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -2107,7 +2130,7 @@ dependencies = [
|
||||
"ecolor",
|
||||
"emath",
|
||||
"epaint_default_fonts",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"harfrust",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
@@ -2115,13 +2138,13 @@ dependencies = [
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
"unicode-segmentation",
|
||||
"vello_cpu",
|
||||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2377,6 +2400,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.5"
|
||||
@@ -2423,7 +2455,7 @@ dependencies = [
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts",
|
||||
"read-fonts 0.41.0",
|
||||
"roxmltree 0.21.1",
|
||||
"smallvec",
|
||||
"windows",
|
||||
@@ -2717,6 +2749,23 @@ dependencies = [
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"png 0.18.1",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"vello_common 0.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.3.0"
|
||||
@@ -2728,9 +2777,9 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"vello_common",
|
||||
"vello_common 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2879,7 +2928,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
"read-fonts 0.41.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -2912,6 +2961,94 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro-interpret",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"pic-scale",
|
||||
"vello_cpu 0.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
"phf 0.13.1",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-postscript"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -5001,7 +5138,7 @@ dependencies = [
|
||||
"linebender_resource_handle",
|
||||
"parlance",
|
||||
"parley_data",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5080,17 +5217,37 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
@@ -5098,7 +5255,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5107,13 +5277,22 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
@@ -5242,6 +5421,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"photocraft-automation",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-doc",
|
||||
"photocraft-engine",
|
||||
@@ -5434,9 +5614,11 @@ version = "0.20.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
"craft-pdf",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"imagesize",
|
||||
"kurbo 0.13.1",
|
||||
"lopdf",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
@@ -5585,7 +5767,7 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5681,6 +5863,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pic-scale"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
@@ -6306,6 +6498,16 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.41.0"
|
||||
@@ -6313,7 +6515,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types 0.12.5",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -7260,6 +7473,16 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.44.0"
|
||||
@@ -7267,7 +7490,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8272,6 +8505,40 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"png 0.18.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"png 0.18.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.2.0"
|
||||
@@ -8287,6 +8554,19 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo 0.1.1",
|
||||
"hashbrown 0.17.1",
|
||||
"png 0.18.1",
|
||||
"vello_common 0.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.2.0"
|
||||
@@ -8294,9 +8574,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo",
|
||||
"glifo 0.3.0",
|
||||
"hashbrown 0.17.1",
|
||||
"vello_common",
|
||||
"vello_common 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -40,6 +40,7 @@ photocraft-ipp = { path = "crates/ipp" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["raster"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" }
|
||||
@@ -203,3 +204,8 @@ eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968157
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
# craft-pdf's vendored Hayro trees: consumers patch the same craft-libs revision (crates/pdf README).
|
||||
hayro = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
@@ -70,3 +70,51 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Optional PDF reader (craft-pdf)
|
||||
|
||||
PhotoCraft imports PDF pages through craft-pdf (https://github.com/storytold/craft-libs,
|
||||
MIT OR Apache-2.0), pinned to revision c5a1cbfb5f9049f9270955caffb5bee5997aebf7. It vendors
|
||||
Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and
|
||||
hayro-cmap 0.1.0 (https://github.com/LaurenzV/hayro, MIT OR Apache-2.0). The reader bundles
|
||||
the standard-14 Foxit fonts (BSD-3-Clause), Adobe CMaps (BSD-3-Clause) and a Compact ICC
|
||||
profile by Clinton Ingram (CC0-1.0); per-file sources and hashes are in craft-libs
|
||||
ATTRIBUTION.md. No PDF assets are copied into this repository, and PDFium is no longer used or
|
||||
shipped.
|
||||
|
||||
Licence texts for the optional PDF reader
|
||||
|
||||
Hayro (MIT):
|
||||
Copyright (c) The Hayro Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Standard-14 Foxit fonts (BSD-3-Clause):
|
||||
Copyright 2014 PDFium Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Adobe CMaps (BSD-3-Clause):
|
||||
Copyright 1990-2023 Adobe. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
Neither the name of Adobe nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Compact ICC profile: CC0 1.0 Universal (public domain dedication), by Clinton Ingram (saucecontrol/Compact-ICC-Profiles).
|
||||
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 68 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 69 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Star · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
|
||||
@@ -33,7 +33,7 @@ required-features = ["gpu"]
|
||||
photocraft-automation = { path = "../../crates/automation" }
|
||||
photocraft-doc = { workspace = true }
|
||||
photocraft-engine = { workspace = true }
|
||||
photocraft-io = { workspace = true }
|
||||
photocraft-io = { workspace = true, features = ["pdf-editable"] }
|
||||
photocraft-raster = { workspace = true }
|
||||
photocraft-format = { path = "../../crates/format" }
|
||||
photocraft-codecs = { workspace = true }
|
||||
@@ -47,6 +47,8 @@ serde = { workspace = true }
|
||||
tempfile = "3"
|
||||
# Reading back a saved PSD's merged-image flag (#8064).
|
||||
photocraft-psd = { workspace = true }
|
||||
# A real ICC profile for the --metadata test (#9466).
|
||||
photocraft-cms = { workspace = true }
|
||||
# Synthetic camera raws (#7789).
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
|
||||
|
||||
@@ -17,7 +17,7 @@ use clap::parser::ValueSource;
|
||||
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
|
||||
use photocraft_codecs::{JpegFormat, TiffCompression};
|
||||
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
|
||||
use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed};
|
||||
use serde_json::Value;
|
||||
|
||||
/// photocraft-cli: headless PhotoCraft
|
||||
@@ -93,6 +93,12 @@ pub(crate) struct ExportArgs {
|
||||
/// real image
|
||||
#[arg(long)]
|
||||
pub(crate) no_maximize_compatibility: bool,
|
||||
/// The document metadata a flat export embeds: all (the default) keeps the document's XMP
|
||||
/// packet and free-form text where the format holds them, as Save As does; none leaves them
|
||||
/// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and
|
||||
/// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP
|
||||
#[arg(long, value_name = "none|all", value_parser = metadata)]
|
||||
pub(crate) metadata: Option<XmpEmbed>,
|
||||
}
|
||||
|
||||
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
|
||||
@@ -113,6 +119,16 @@ pub(crate) struct ConvertArgs {
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
/// PDF import resolution in DPI (default 300)
|
||||
#[arg(long, value_name = "DPI", value_parser = pdf_dpi)]
|
||||
pub(crate) pdf_dpi: Option<f32>,
|
||||
/// PDF pages to import, 1-based: "3" or "2-7" (default all)
|
||||
#[arg(long)]
|
||||
pub(crate) pdf_pages: Option<String>,
|
||||
/// Import PDF paths and embedded-font text as editable layers (experimental; unsupported
|
||||
/// pages fall back to raster, and embedded fonts may change when edited or reopened)
|
||||
#[arg(long)]
|
||||
pub(crate) pdf_editable: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) gpu: GpuArgs,
|
||||
#[command(flatten)]
|
||||
@@ -312,8 +328,12 @@ enum ValueError {
|
||||
TiffCompression,
|
||||
#[error("expected baseline, optimized or progressive")]
|
||||
JpegFormat,
|
||||
#[error("expected none or all")]
|
||||
Metadata,
|
||||
#[error("expected on or off")]
|
||||
OnOff,
|
||||
#[error("expected a positive number")]
|
||||
PdfDpi,
|
||||
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
|
||||
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
|
||||
Bridge,
|
||||
@@ -382,10 +402,24 @@ fn on_off(s: &str) -> Result<bool, ValueError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF import DPI: a finite number greater than zero. The importer's fallback to 300 DPI is
|
||||
/// for absent preferences, not for a mistyped flag.
|
||||
fn pdf_dpi(s: &str) -> Result<f32, ValueError> {
|
||||
s.parse::<f32>().ok().filter(|dpi| dpi.is_finite() && *dpi > 0.0).ok_or(ValueError::PdfDpi)
|
||||
}
|
||||
|
||||
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
|
||||
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
|
||||
}
|
||||
|
||||
fn metadata(s: &str) -> Result<XmpEmbed, ValueError> {
|
||||
match s {
|
||||
"none" => Ok(XmpEmbed::None),
|
||||
"all" => Ok(XmpEmbed::All),
|
||||
_ => Err(ValueError::Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Bridge {
|
||||
@@ -532,3 +566,19 @@ fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, tr
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `--pdf-dpi` only takes a positive finite number: a mistyped value is refused at parse time
|
||||
/// instead of silently becoming the importer's 300 DPI fallback.
|
||||
#[test]
|
||||
fn pdf_dpi_rejects_non_positive_and_non_finite_values() {
|
||||
assert!(pdf_dpi("300").is_ok());
|
||||
assert!(pdf_dpi("0.5").is_ok());
|
||||
for bad in ["0", "-1", "nan", "inf", "-inf", "abc"] {
|
||||
assert!(pdf_dpi(bad).is_err(), "{bad} should be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,29 @@ fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `N` or `N-M`, 1-based and inclusive, into a normalized `(first, last)`.
|
||||
fn parse_pdf_pages(s: &str) -> Result<(u32, u32)> {
|
||||
let (a, b) = s.split_once('-').unwrap_or((s, s));
|
||||
let bad = || CliError::Other(format!("invalid --pdf-pages {s:?}; use N or N-M"));
|
||||
let a = a.trim().parse::<u32>().map_err(|_| bad())?;
|
||||
let b = b.trim().parse::<u32>().map_err(|_| bad())?;
|
||||
if a == 0 || b == 0 {
|
||||
return Err(CliError::Other(format!("--pdf-pages is 1-based; {s:?} starts at 0")));
|
||||
}
|
||||
Ok((a.min(b), a.max(b)))
|
||||
}
|
||||
|
||||
pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result {
|
||||
let (input, output) = (&a.input, &a.output);
|
||||
let opts = ExportOptions::from(&a.export);
|
||||
refuse_overwriting_input(a.in_place, input, output)?;
|
||||
let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() };
|
||||
let import_opts = photocraft_io::ImportOptions {
|
||||
psd_high_memory_approved: a.approve_high_memory,
|
||||
pdf_dpi: a.pdf_dpi.unwrap_or(300.0),
|
||||
pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?,
|
||||
pdf_editable: a.pdf_editable,
|
||||
..Default::default()
|
||||
};
|
||||
let mut o = files::open_with_options(input, &import_opts)?;
|
||||
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
|
||||
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
|
||||
|
||||
@@ -34,6 +34,10 @@ impl From<&ExportArgs> for ExportOptions {
|
||||
}
|
||||
o.encode.dds.mipmaps = a.dds_mipmaps;
|
||||
o.psd_maximize_compatibility = !a.no_maximize_compatibility;
|
||||
// Conversions keep the whole packet unless asked, as Save As does (#733, #9466).
|
||||
if let Some(m) = a.metadata {
|
||||
o.xmp = m;
|
||||
}
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,6 +980,88 @@ fn jpeg_format_chooses_the_scans() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat
|
||||
/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes
|
||||
/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the
|
||||
/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way.
|
||||
/// A value it doesn't name is a usage error and nothing is written.
|
||||
#[test]
|
||||
fn metadata_none_leaves_out_the_document_xmp() {
|
||||
const XMP: &str = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
|
||||
<rdf:Description rdf:about=\"\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\">\
|
||||
<photoshop:DocumentAncestors><rdf:Bag><rdf:li>xmp.did:11111111-2222-3333-4444-555555555555</rdf:li></rdf:Bag></photoshop:DocumentAncestors>\
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>";
|
||||
let d = tmp("metadata");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let a = input.join("a.png");
|
||||
let data: Vec<u8> = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect();
|
||||
let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
|
||||
img.meta.xmp = Some(XMP.into());
|
||||
img.meta.dpi = Some((300.0, 300.0));
|
||||
img.meta.text = vec![("Comment".into(), "free-form-note".into())];
|
||||
// Display P3 rather than sRGB, so no export can pass for keeping it by writing its default.
|
||||
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec();
|
||||
img.icc = Some(p3.clone());
|
||||
std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors");
|
||||
let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap();
|
||||
let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note");
|
||||
assert!(ancestors(&a) && note(&a), "the input carries the packet and the text");
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for ext in ["png", "webp", "jpg", "tif"] {
|
||||
let mut converted_files = Vec::new();
|
||||
for flag in [None, Some("all"), Some("none")] {
|
||||
let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default();
|
||||
let name = format!("{ext}-{}", flag.unwrap_or("default"));
|
||||
let converted = d.join(format!("convert-{name}.{ext}"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra));
|
||||
let ran = d.join(format!("run-{name}.{ext}"));
|
||||
ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra));
|
||||
let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat());
|
||||
assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}"));
|
||||
for out in [&converted, &ran, &batched] {
|
||||
assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display());
|
||||
// PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't.
|
||||
assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display());
|
||||
}
|
||||
converted_files.push(converted);
|
||||
}
|
||||
// Only the XMP and text go: the pixels, profile and resolution are the default export's.
|
||||
let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2]));
|
||||
assert_eq!(none.data(), default.data(), "{ext}: pixels");
|
||||
assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile");
|
||||
assert_eq!(none.icc, default.icc, "{ext}: ICC profile");
|
||||
assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution");
|
||||
assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi);
|
||||
}
|
||||
// A layered save keeps the document's XMP whatever the option.
|
||||
for ext in ["psd", "pcraft"] {
|
||||
let out = d.join(format!("layered.{ext}"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"]));
|
||||
let back = d.join(format!("layered-{ext}.png"));
|
||||
ok(bin().arg("convert").arg(&out).arg(&back));
|
||||
assert!(ancestors(&back), "{ext} kept the packet");
|
||||
}
|
||||
for bad in ["None", "exif", "0", ""] {
|
||||
let out = d.join("bad.png");
|
||||
let (mut convert, mut run) = (bin(), bin());
|
||||
convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]);
|
||||
run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]);
|
||||
for cmd in [&mut convert, &mut run] {
|
||||
let o = cmd.output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}");
|
||||
assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}");
|
||||
assert!(!out.exists(), "--metadata {bad} wrote a file");
|
||||
}
|
||||
let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]);
|
||||
assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}");
|
||||
assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
|
||||
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
|
||||
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
|
||||
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
|
||||
//! - **Wayland**: winit delivers file drops, and egui's last pointer position is used as fallback.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
//!
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
|
||||
<path d="M3 20h18" />
|
||||
<path d="M17 4v12" />
|
||||
<path d="M12 10v6" />
|
||||
<path d="M7 7v9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -0,0 +1,17 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
|
||||
<path d="M3 12h18" />
|
||||
<path d="M17 6v12" />
|
||||
<path d="M12 9v6" />
|
||||
<path d="M7 7v10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -0,0 +1,17 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PhotoCraft's own Type options-bar glyph for vertical text (not from an icon set). -->
|
||||
<path d="M3 4h18" />
|
||||
<path d="M17 8v12" />
|
||||
<path d="M12 8v6" />
|
||||
<path d="M7 8v9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 393 B |
@@ -35,6 +35,27 @@ Warnings do not change the saved document's font declarations and do not stop
|
||||
the operation. To list or replace the missing families intentionally, use
|
||||
`type.resolveMissingFonts` on the document.
|
||||
|
||||
### Document metadata
|
||||
|
||||
`convert`, `run --out` and `batch` keep the document's XMP packet in a flat
|
||||
export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form
|
||||
text where the format holds that too (PNG text chunks, TIFF's named text tags
|
||||
such as ImageDescription; JPEG and WebP have no place for it), as Save As
|
||||
does. `--metadata none` leaves them out, as Export As's Metadata: None does:
|
||||
a Photoshop file's XMP can list every placed document's id
|
||||
(`DocumentAncestors`) and the text of every type layer, and can be far larger
|
||||
than the image. `--metadata all` is the default. The option changes nothing
|
||||
else: the ICC profile, EXIF and resolution are written as they are without
|
||||
it, where the format holds them. An Indexed Color document saved as a palette
|
||||
PNG carries no XMP, text, ICC profile or EXIF with either value, only its
|
||||
resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full
|
||||
metadata scrub.
|
||||
|
||||
```sh
|
||||
photocraft-cli batch --actions resize.json --in masters/ --out web/ \
|
||||
--format webp --metadata none
|
||||
```
|
||||
|
||||
### Export selected adjustment layers as a .cube LUT
|
||||
|
||||
The engine command `file.export.colorLookupTables` creates a 3D LUT from an
|
||||
|
||||
@@ -315,3 +315,7 @@ sync_github_name = false
|
||||
[people.davidvhk]
|
||||
real_name = "David Vanhoucke"
|
||||
sync_github_name = true
|
||||
|
||||
[people.rmsff]
|
||||
real_name = "Serikov Roman"
|
||||
sync_github_name = false
|
||||
@@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
|
||||
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
|
||||
}
|
||||
|
||||
/// How many times over a stored mesh's triangles may cover its deformed box, counting each
|
||||
/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a
|
||||
/// crafted one stacks large triangles to multiply the drawing work.
|
||||
const MAX_MESH_COVERAGE: f64 = 16.0;
|
||||
|
||||
/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file
|
||||
/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored
|
||||
/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex
|
||||
/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose
|
||||
/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times.
|
||||
pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option<Surface> {
|
||||
let n = mesh.verts.len();
|
||||
if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for p in mesh.verts.iter().chain(deformed) {
|
||||
if !(p[0].is_finite() && p[1].is_finite()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for p in deformed {
|
||||
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
|
||||
}
|
||||
let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0);
|
||||
if area > MAX_WARP_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let covered: f64 = mesh
|
||||
.tris
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1]));
|
||||
let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0;
|
||||
span(xs) * span(ys)
|
||||
})
|
||||
.sum();
|
||||
if covered > MAX_MESH_COVERAGE * area {
|
||||
return None;
|
||||
}
|
||||
let order: Vec<usize> = (0..mesh.tris.len()).collect();
|
||||
Some(render(src, bounds, mesh, deformed, &order, interp))
|
||||
}
|
||||
|
||||
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
|
||||
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
|
||||
/// result when none was given.
|
||||
@@ -1267,6 +1311,50 @@ mod tests {
|
||||
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_mesh_is_drawn_as_it_is() {
|
||||
// A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box,
|
||||
// sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the
|
||||
// mesh are dropped.
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
|
||||
for y in 0..48 {
|
||||
for x in 0..48 {
|
||||
let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0;
|
||||
s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
}
|
||||
}
|
||||
let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 };
|
||||
let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0];
|
||||
let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect();
|
||||
let b = s.content_bounds();
|
||||
let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws");
|
||||
let ob = out.content_bounds();
|
||||
let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0);
|
||||
for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() {
|
||||
let w = f64::from(px[0] * px[3]);
|
||||
let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5);
|
||||
(sw, sx, sy) = (sw + w, sx + w * x, sy + w * y);
|
||||
}
|
||||
let want = map([12.0, 20.0]);
|
||||
assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]);
|
||||
// The source column x = 44 is outside the mesh: nothing lands right of the sheared box.
|
||||
assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}");
|
||||
// Malformed meshes are refused rather than drawn.
|
||||
assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none());
|
||||
let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() };
|
||||
assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none());
|
||||
let mut far = deformed.clone();
|
||||
far[2] = [f64::NAN, 0.0];
|
||||
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
|
||||
far[2] = [1.0e6, 1.0e6];
|
||||
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
|
||||
// The same two triangles stacked 40 times: far more drawing than the box needs.
|
||||
let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() };
|
||||
assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none());
|
||||
let none = PuppetMesh { tris: Vec::new(), ..mesh };
|
||||
assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pins_or_unmoved_pins_are_identity() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
@@ -219,11 +219,44 @@ pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32,
|
||||
}
|
||||
}
|
||||
if anti_alias {
|
||||
antialias(&mut m, w, h);
|
||||
let tol = (tolerance + 1e-3).floor().max(0.0);
|
||||
let src = m.clone();
|
||||
for (i, v) in m.iter_mut().enumerate() {
|
||||
if src[i] == 0.0 && touches_region(i % w, i / w, w, h, |x, y| src[y * w + x] > 0.0) {
|
||||
let p = px[i];
|
||||
let over = (0..3).map(|c| (p[c] - target[c]).abs() * 255.0).fold(0.0f32, f32::max) - tol;
|
||||
*v = f32::from(fringe_coverage(over, p[3].min(target[3]) * 255.0)) / 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// How many levels past the tolerance the anti-aliased fringe fades over, from fully selected to
|
||||
/// nothing. Measured in the reference application (#11776): 3 levels past gives 251, 5 gives
|
||||
/// 249 and 123 gives 110; a straight fade to zero at 216 levels gives all three.
|
||||
const FRINGE_LEVELS: f32 = 216.0;
|
||||
|
||||
/// Coverage (0–255) of an anti-aliased fringe pixel: a neighbour of the region whose most
|
||||
/// different colour channel is `over` levels past the tolerance, and `alpha` (0–255) the lower
|
||||
/// of its and the clicked pixel's opacity. Photoshop keeps the region itself fully selected and
|
||||
/// softens only the pixels just outside it, fading with the distance past the tolerance; a
|
||||
/// neighbour without colour (transparent, next to a shape on its own layer) gets none.
|
||||
fn fringe_coverage(over: f32, alpha: f32) -> u8 {
|
||||
let k = (1.0 - over.max(0.0) / FRINGE_LEVELS).clamp(0.0, 1.0) * (alpha / 255.0).clamp(0.0, 1.0);
|
||||
(k * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// True when one of the 8 neighbours of `(x, y)` (inside a `w` x `h` area) is in the region.
|
||||
fn touches_region(x: usize, y: usize, w: usize, h: usize, selected: impl Fn(usize, usize) -> bool) -> bool {
|
||||
(-1i32..=1).any(|dy| {
|
||||
(-1i32..=1).any(|dx| {
|
||||
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
|
||||
(dx, dy) != (0, 0) && nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && selected(nx as usize, ny as usize)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Straight RGBA8 copy of `area` of a surface, read in parallel bands on native.
|
||||
pub fn rgba8_image(s: &Surface, area: Rect) -> Vec<[u8; 4]> {
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
@@ -270,6 +303,22 @@ impl Region {
|
||||
/// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box.
|
||||
/// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`.
|
||||
pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Option<Region> {
|
||||
wand_region_within(img, area, seed, tolerance, contiguous, anti_alias, None)
|
||||
}
|
||||
|
||||
/// [`wand_region`] whose contiguous fill only passes through pixels at least half selected in
|
||||
/// `within` (the current selection's coverage over `area`, row-major): Subtract and Intersect
|
||||
/// grow the region from the seed through the selection only (#11779). A seed outside it finds
|
||||
/// nothing. A global match (`contiguous` off) ignores `within`.
|
||||
pub fn wand_region_within(
|
||||
img: &[[u8; 4]],
|
||||
area: Rect,
|
||||
seed: (i32, i32),
|
||||
tolerance: f32,
|
||||
contiguous: bool,
|
||||
anti_alias: bool,
|
||||
within: Option<&[f32]>,
|
||||
) -> Option<Region> {
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
return None;
|
||||
}
|
||||
@@ -277,6 +326,11 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
|
||||
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
|
||||
return None;
|
||||
}
|
||||
let within = within.filter(|_| contiguous).filter(|m| m.len() >= w * h);
|
||||
let inside = |i: usize| within.is_none_or(|m| m.get(i).is_some_and(|v| *v >= 0.5));
|
||||
if !inside((seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize) {
|
||||
return None;
|
||||
}
|
||||
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
|
||||
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
|
||||
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
|
||||
@@ -293,10 +347,10 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
|
||||
continue;
|
||||
}
|
||||
let (mut l, mut r) = (x, x);
|
||||
while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) {
|
||||
while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) && inside(row + l - 1) {
|
||||
l -= 1;
|
||||
}
|
||||
while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) {
|
||||
while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) && inside(row + r + 1) {
|
||||
r += 1;
|
||||
}
|
||||
marks[row + l..=row + r].fill(255);
|
||||
@@ -308,7 +362,7 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
|
||||
let nrow = ny * w;
|
||||
let mut in_run = false;
|
||||
for nx in l..=r {
|
||||
let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]);
|
||||
let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]) && inside(nrow + nx);
|
||||
if ok && !in_run {
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
@@ -356,25 +410,12 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
|
||||
let x = x0 + xx;
|
||||
let v = at(x, y);
|
||||
*o = v;
|
||||
if !anti_alias {
|
||||
continue;
|
||||
}
|
||||
// Same rule as `antialias`: edge pixels blend with their 3×3 neighbourhood; neighbours
|
||||
// outside the area count as the pixel itself.
|
||||
let mut sum = 0u32;
|
||||
let mut edge = false;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
|
||||
let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { at(nx as usize, ny as usize) };
|
||||
edge |= n != v;
|
||||
sum += n as u32;
|
||||
}
|
||||
}
|
||||
if edge {
|
||||
let avg = sum as f32 / (9.0 * 255.0);
|
||||
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
|
||||
*o = (f * 255.0).round() as u8;
|
||||
// Same rule as `magic_wand`: the region stays fully selected; a pixel just outside
|
||||
// it gets a fringe by how far its colour is past the tolerance.
|
||||
if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) {
|
||||
let p = img[y * w + x];
|
||||
let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0);
|
||||
*o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3])));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -435,32 +476,6 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
|
||||
}
|
||||
}
|
||||
|
||||
/// Softens hard mask edges: edge pixels become the 3×3 average.
|
||||
fn antialias(m: &mut [f32], w: usize, h: usize) {
|
||||
let src = m.to_vec();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let mut sum = 0.0;
|
||||
let mut cnt = 0.0;
|
||||
let mut edge = false;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
let (xx, yy) = (x as i32 + dx, y as i32 + dy);
|
||||
let v = if xx < 0 || yy < 0 || xx >= w as i32 || yy >= h as i32 { src[i] } else { src[yy as usize * w + xx as usize] };
|
||||
edge |= v != src[i];
|
||||
sum += v;
|
||||
cnt += 1.0;
|
||||
}
|
||||
}
|
||||
if edge {
|
||||
// Keep selected pixels mostly selected, add a soft fringe.
|
||||
m[i] = if src[i] > 0.5 { 0.5 + 0.5 * sum / cnt } else { 0.5 * sum / cnt };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour Range: coverage falls off linearly with the per-channel colour
|
||||
/// distance; `fuzziness` in levels (0–200).
|
||||
pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec<f32> {
|
||||
@@ -780,10 +795,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
mod distance;
|
||||
#[cfg(test)]
|
||||
mod ellipse_aa_edge_tests;
|
||||
|
||||
mod inverse;
|
||||
mod lasso;
|
||||
mod marquee;
|
||||
mod morphology;
|
||||
#[cfg(test)]
|
||||
mod wand_antialias_tests;
|
||||
|
||||
pub use distance::edt;
|
||||
pub use inverse::inverse_surface;
|
||||
|
||||
@@ -106,7 +106,9 @@ fn wand_contiguous_vs_global() {
|
||||
let g = magic_wand(&px, a, (3, 3), 10.0, false, false);
|
||||
assert_eq!(g.iter().filter(|v| **v > 0.0).count(), 80);
|
||||
let aa = magic_wand(&px, a, (3, 3), 10.0, true, true);
|
||||
assert!(aa[3 * 20 + 6] > 0.0 && aa[3 * 20 + 6] < 1.0);
|
||||
// #11776: anti-aliasing keeps the region whole and adds no fringe next to a colour this far
|
||||
// past the tolerance (white is 245 levels from red, beyond the fringe's fade).
|
||||
assert_eq!((aa[3 * 20 + 5], aa[3 * 20 + 6]), (1.0, 0.0));
|
||||
assert!(magic_wand(&px, a, (50, 3), 10.0, true, false).iter().all(|v| *v == 0.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The Magic Wand's Anti-alias against the reference measurement (#11776): the region itself
|
||||
//! stays fully selected, and a one-pixel soft fringe lies only outside it, next to colours just
|
||||
//! past the tolerance. Document 800 x 600: a solid red rectangle 100,100–300,250 on a transparent
|
||||
//! layer, and a grey ramp of ten 40 px blocks (grey 100, 105, …, 145) from x 100 at y 350–450,
|
||||
//! on its own transparent layer or composited on white (Sample All Layers).
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use super::{Region, magic_wand, wand_region};
|
||||
|
||||
const AREA: Rect = Rect { x0: 0, y0: 0, x1: 800, y1: 600 };
|
||||
const W: usize = 800;
|
||||
const H: usize = 600;
|
||||
|
||||
fn red_rectangle() -> Vec<[u8; 4]> {
|
||||
let mut img = vec![[0u8; 4]; W * H];
|
||||
for y in 100..250 {
|
||||
for x in 100..300 {
|
||||
img[y * W + x] = [220, 30, 30, 255];
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// The grey ramp at y 350–450 on a transparent layer, or on white when `on_white`.
|
||||
fn ramp(on_white: bool) -> Vec<[u8; 4]> {
|
||||
let mut img = vec![if on_white { [255u8; 4] } else { [0u8; 4] }; W * H];
|
||||
for y in 350..450 {
|
||||
for b in 0..10u8 {
|
||||
let g = 100 + 5 * b;
|
||||
for x in 100 + 40 * b as usize..140 + 40 * b as usize {
|
||||
img[y * W + x] = [g, g, g, 255];
|
||||
}
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// (bounds of every touched pixel, fully selected count, partly selected count).
|
||||
fn stats(r: &Region) -> (Rect, usize, usize) {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
let (mut full, mut part) = (0, 0);
|
||||
for y in r.bbox.y0..r.bbox.y1 {
|
||||
for x in r.bbox.x0..r.bbox.x1 {
|
||||
let v = (r.at(x, y) * 255.0).round() as u8;
|
||||
if v == 0 {
|
||||
continue;
|
||||
}
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
if v == 255 { full += 1 } else { part += 1 }
|
||||
}
|
||||
}
|
||||
(Rect::new(x0, y0, x1, y1), full, part)
|
||||
}
|
||||
|
||||
fn coverage(r: &Region, x: i32, y: i32) -> u8 {
|
||||
(r.at(x, y) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_next_to_a_clearly_different_colour_gets_no_fringe() {
|
||||
let img = red_rectangle();
|
||||
for aa in [true, false] {
|
||||
let r = wand_region(&img, AREA, (150, 150), 32.0, true, aa).unwrap();
|
||||
assert_eq!(stats(&r), (Rect::new(100, 100, 300, 250), 30000, 0), "anti-alias {aa}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grey_ramp_gets_one_nearly_selected_column_past_the_tolerance() {
|
||||
let img = ramp(false);
|
||||
// Tolerance 32 from grey 100: blocks 100 to 130 (x 100–380) fully selected; the 135 block
|
||||
// is 3 levels past it, so its first column is nearly selected.
|
||||
let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap();
|
||||
let (bounds, full, part) = stats(&r);
|
||||
assert_eq!((full, part), (280 * 100, 100), "{bounds:?}");
|
||||
assert_eq!(bounds, Rect::new(100, 350, 381, 450));
|
||||
for y in [350, 400, 449] {
|
||||
assert_eq!(coverage(&r, 379, y), 255, "the region's last column stays fully selected");
|
||||
assert_eq!(coverage(&r, 380, y), 251, "one fringe column of the next block, as measured");
|
||||
assert_eq!(coverage(&r, 381, y), 0, "only one column");
|
||||
}
|
||||
// Transparent pixels above and below the ramp have no colour: no fringe there.
|
||||
assert_eq!(coverage(&r, 200, 349), 0);
|
||||
assert_eq!(coverage(&r, 200, 450), 0);
|
||||
// Tolerance 10: x 100–220 plus one partly selected column of the 115 block.
|
||||
let r = wand_region(&img, AREA, (110, 400), 10.0, true, true).unwrap();
|
||||
let (bounds, full, part) = stats(&r);
|
||||
assert_eq!((bounds, full, part), (Rect::new(100, 350, 221, 450), 120 * 100, 100));
|
||||
assert_eq!(coverage(&r, 220, 400), 249, "5 levels past tolerance 10, as measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anti_alias_off_has_no_partly_selected_pixels() {
|
||||
let img = ramp(false);
|
||||
let r = wand_region(&img, AREA, (110, 400), 32.0, true, false).unwrap();
|
||||
assert_eq!(stats(&r), (Rect::new(100, 350, 380, 450), 28000, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_8_bit_and_f32_wands_draw_the_same_fringe() {
|
||||
let img = ramp(false);
|
||||
let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
|
||||
for (tol, contiguous) in [(32.0, true), (10.0, true), (32.0, false)] {
|
||||
let r = wand_region(&img, AREA, (110, 400), tol, contiguous, true).unwrap();
|
||||
let m = magic_wand(&f, AREA, (110, 400), tol, contiguous, true);
|
||||
for y in 340..460 {
|
||||
for x in 90..520 {
|
||||
let want = (m[y as usize * W + x as usize] * 255.0).round() as u8;
|
||||
assert_eq!(coverage(&r, x, y), want, "tolerance {tol}, contiguous {contiguous} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_the_composite_the_white_around_the_ramp_gets_the_measured_fringe() {
|
||||
// Sample All Layers: the ramp on white. White is 155 levels from grey 100, 123 past
|
||||
// tolerance 32; the reference application's fringe there reads 110 (Photoshop 2025, the
|
||||
// same points; the issue reports 131–177 around the ramp).
|
||||
let img = ramp(true);
|
||||
let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap();
|
||||
for (x, y) in [(200, 349), (200, 450), (99, 400), (99, 349)] {
|
||||
assert_eq!(coverage(&r, x, y), 110, "{x},{y}");
|
||||
}
|
||||
assert_eq!(coverage(&r, 380, 400), 251, "the near-miss block still gets the stronger fringe");
|
||||
assert_eq!(coverage(&r, 200, 400), 255, "inside stays fully selected");
|
||||
}
|
||||
@@ -732,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
| "path.info"
|
||||
| "path.addToSelection"
|
||||
| "path.subtractFromSelection"
|
||||
| "path.intersectWithSelection"
|
||||
| "path.set"
|
||||
| "actions.insertPath"
|
||||
| "path.transform"
|
||||
| "path.moveAnchors"
|
||||
| "path.moveHandle"
|
||||
|
||||
@@ -60,7 +60,11 @@ fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka {
|
||||
}
|
||||
let mut o = [0.0; 5];
|
||||
for i in 0..4 {
|
||||
o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a;
|
||||
// Over nothing, under a covering source, or where both inks agree, the result is the
|
||||
// source's ink exactly: `s * sa / sa` can be one ulp off, which a later mode or
|
||||
// rounding step can turn into a whole level.
|
||||
let shows_through = b[4] > 0.0 && sa < 1.0;
|
||||
o[i] = if !shows_through || b[i] == s[i] { s[i] } else { (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a };
|
||||
}
|
||||
o[4] = a;
|
||||
o
|
||||
@@ -762,6 +766,37 @@ fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool {
|
||||
}) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children)))
|
||||
}
|
||||
|
||||
/// A proptest case from main's CI: a masked shape below 100% Fill, then Posterize in Color
|
||||
/// Burn (Paint.NET) inside an isolated group. The burned brightness is exactly 73.5/255.
|
||||
#[test]
|
||||
fn posterize_in_paint_net_color_burn_on_a_rounding_tie_matches_the_reference() {
|
||||
let shape = LayerSpec::Pixels {
|
||||
kind: Kind::Shape,
|
||||
blend: BlendMode::Normal,
|
||||
fills: vec![(Rect::new(0, 0, 1, 1), [0.47422728, 0.0, 0.0, 0.0, 1.0])],
|
||||
opacity: 1.0,
|
||||
fill: 0.876696,
|
||||
clipped: false,
|
||||
visible: true,
|
||||
mask: Some(MaskSpec { rects: vec![], default: 0.06425183, density: 1.0, enabled: true }),
|
||||
};
|
||||
let posterize = LayerSpec::Pixels {
|
||||
kind: Kind::Adjust(AdjSpec::Posterize(4)),
|
||||
blend: BlendMode::PaintNetColorBurn,
|
||||
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
|
||||
opacity: 1.0,
|
||||
fill: 1.0,
|
||||
clipped: false,
|
||||
visible: true,
|
||||
mask: None,
|
||||
};
|
||||
let group = LayerSpec::Group { children: vec![shape, posterize], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None };
|
||||
let s = Spec { w: 8, h: 6, layers: vec![group] };
|
||||
let mut d = doc(s.w as u32, s.h as u32);
|
||||
d.layers = s.layers.iter().map(build).collect();
|
||||
assert_matches_reference(&d);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
|
||||
|
||||
|
||||
@@ -344,6 +344,9 @@ impl Tile {
|
||||
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
|
||||
const MAX_TAPS: usize = 8;
|
||||
|
||||
/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite.
|
||||
pub const MIN_PLACEMENT_SCALE: f64 = 1e-6;
|
||||
|
||||
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
|
||||
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
|
||||
fn pixel_taps(inv_scale: f64) -> usize {
|
||||
@@ -387,7 +390,9 @@ impl Placement {
|
||||
let base = if link { anchor } else { (0.0, 0.0) };
|
||||
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
|
||||
let s = f64::from(scale);
|
||||
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
|
||||
// Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern
|
||||
// well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %.
|
||||
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 };
|
||||
let a = f64::from(angle).to_radians();
|
||||
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
|
||||
}
|
||||
@@ -608,6 +613,31 @@ mod tests {
|
||||
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which
|
||||
/// used to fall back to 100 % and show the stripes full size.
|
||||
#[test]
|
||||
fn placement_keeps_scales_below_a_tenth_of_a_percent() {
|
||||
let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0);
|
||||
let (u, v) = place.map(1.0, 2.0);
|
||||
assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}");
|
||||
// 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel
|
||||
// covers 1250 of them, so every pixel is their mean.
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
|
||||
for x in (0..8).step_by(2) {
|
||||
s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap();
|
||||
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
|
||||
assert!((p[0] - 0.5).abs() < 0.1, "{p:?}");
|
||||
}
|
||||
// 0, negative and non-finite scales still draw at 100 %.
|
||||
for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] {
|
||||
let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0);
|
||||
assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_at_hostile_phases_does_not_overflow() {
|
||||
let patterns = [checker()];
|
||||
|
||||
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::adjust::CurvePoint;
|
||||
|
||||
/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern
|
||||
/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog.
|
||||
pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0);
|
||||
|
||||
/// Gradient geometry (fill layers, gradient overlays, gradient strokes).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GradientStyle {
|
||||
|
||||
@@ -564,6 +564,12 @@ impl SmartObject {
|
||||
perspective: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the placed source lands (transform, perspective and warp), as JSON: the placement a
|
||||
/// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for.
|
||||
pub fn placement_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! #11897: the Brush's chosen Clear on the Background paints as Normal. The options bar turns
|
||||
//! Clear off at the press there (`clear_off_on_background` in ui-egui), so the engine never sees
|
||||
//! it; a `"mode": "clear"` stroke that does reach the Background is a held `~`, which keeps
|
||||
//! erasing to the background colour. On a pixel layer Clear still erases, and the Eraser on the
|
||||
//! Background still paints the background colour.
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
|
||||
s.tools.foreground = BLACK;
|
||||
s.tools.background = GREEN;
|
||||
s
|
||||
}
|
||||
|
||||
fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
|
||||
let a: Vec<f32> = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect();
|
||||
[a[0], a[1], a[2], a[3]]
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02)
|
||||
}
|
||||
|
||||
/// A hard stroke across (10..30, 10), as the canvas sends it in Clear mode.
|
||||
fn clear_stroke() -> Value {
|
||||
json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"})
|
||||
}
|
||||
|
||||
/// A held `~` on the Background erases to the background colour, as before #11897.
|
||||
#[test]
|
||||
fn a_clear_stroke_on_the_background_still_paints_the_background_colour() {
|
||||
let mut s = session();
|
||||
s.execute("paint.stroke", clear_stroke()).unwrap();
|
||||
let p = pixel(&mut s, 20, 10);
|
||||
assert!(close(p, GREEN), "painted {p:?}, not the foreground");
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Brush Tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_stroke_on_the_background_paints_the_foreground() {
|
||||
let mut s = session();
|
||||
s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0})).unwrap();
|
||||
let p = pixel(&mut s, 20, 10);
|
||||
assert!(close(p, BLACK), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_on_a_pixel_layer_still_erases() {
|
||||
let mut s = session();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
s.execute("paint.stroke", clear_stroke()).unwrap();
|
||||
let p = pixel(&mut s, 20, 10);
|
||||
assert!(close(p, WHITE), "erased down to the white Background: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eraser_on_the_background_still_paints_the_background_colour() {
|
||||
let mut s = session();
|
||||
s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true})).unwrap();
|
||||
let p = pixel(&mut s, 20, 10);
|
||||
assert!(close(p, GREEN), "{p:?}");
|
||||
}
|
||||
@@ -242,9 +242,27 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) {
|
||||
if let Some(wp) = &mut doc.work_path {
|
||||
*wp = wp.transform(a);
|
||||
}
|
||||
// A note's icon point maps with the pixels; its popup keeps its size (#11795). Under Image Size
|
||||
// and Canvas Size it also keeps its offset from the icon, as Photoshop's does. Where it sits
|
||||
// after a turn or flip wasn't measured, so it keeps today's origin there: the top-left of the
|
||||
// mapped rectangle.
|
||||
for n in &mut doc.notes {
|
||||
// An all-zero popup means none is stored (PSD export writes its default one): keep it.
|
||||
if n.popup == [0.0; 4] {
|
||||
n.position = map_pt(a, n.position);
|
||||
continue;
|
||||
}
|
||||
let [px, py] = n.position;
|
||||
let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]);
|
||||
n.position = map_pt(a, n.position);
|
||||
n.popup = map_box(a, n.popup);
|
||||
let [m0, m1, m2, m3, ..] = a.m;
|
||||
let [x, y] = if m1.abs() < EPS && m2.abs() < EPS && m0 > 0.0 && m3 > 0.0 {
|
||||
[n.position[0] + dx, n.position[1] + dy]
|
||||
} else {
|
||||
let [l, t, ..] = map_box(a, n.popup);
|
||||
[l, t]
|
||||
};
|
||||
n.popup = [x, y, x + w, y + h];
|
||||
}
|
||||
for g in &mut doc.measurement.count_groups {
|
||||
for p in &mut g.points {
|
||||
|
||||
@@ -69,6 +69,8 @@ pub mod comps_cmds;
|
||||
pub mod contour_lib_cmds;
|
||||
#[cfg(test)]
|
||||
mod count_delete_group_tests;
|
||||
#[cfg(test)]
|
||||
mod count_history_names_tests;
|
||||
pub mod cutout_cmds;
|
||||
pub mod distort_cmds;
|
||||
pub mod document_preset_cmds;
|
||||
@@ -114,6 +116,8 @@ pub(super) mod migrate_cmds;
|
||||
pub mod mode_cmds;
|
||||
pub mod multichannel_cmds;
|
||||
pub mod notes_cmds;
|
||||
#[cfg(test)]
|
||||
mod notes_stored_values_tests;
|
||||
mod ops;
|
||||
pub mod paint_cmds;
|
||||
mod params;
|
||||
@@ -150,6 +154,8 @@ pub mod type_area_cmds;
|
||||
pub mod type_caret_cmds;
|
||||
pub mod type_cmds;
|
||||
pub mod type_extra_cmds;
|
||||
#[cfg(test)]
|
||||
mod type_indexed_mode_tests;
|
||||
pub mod type_mask_cmds;
|
||||
pub mod type_spell_cmds;
|
||||
pub mod type_styles_cmds;
|
||||
|
||||
@@ -94,6 +94,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000;
|
||||
#[serde(default)]
|
||||
pub struct ActionState {
|
||||
pub list: Vec<Action>,
|
||||
/// Painting-tool strokes are recorded only when the user enables them for this session.
|
||||
#[serde(skip)]
|
||||
pub record_tools: bool,
|
||||
/// Journal length when recording started, and the action index being recorded into.
|
||||
#[serde(skip)]
|
||||
pub recording: Option<(usize, usize)>,
|
||||
@@ -165,7 +168,7 @@ pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if id.starts_with("filter.gallery.")
|
||||
if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM)
|
||||
&& let Some(object) = p.as_object_mut()
|
||||
{
|
||||
object.remove(crate::atn_cmds::RECORDED_EVENT);
|
||||
@@ -276,6 +279,63 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> {
|
||||
Ok((action, from))
|
||||
}
|
||||
|
||||
/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched.
|
||||
fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) {
|
||||
let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect();
|
||||
while let Some((id, params)) = pending.pop() {
|
||||
let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null());
|
||||
if id == "file.automate.batch" {
|
||||
let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") };
|
||||
if let Some(key) = inline {
|
||||
if scoped {
|
||||
continue;
|
||||
}
|
||||
// A present invalid `steps` still wins over `action`, just as at runtime.
|
||||
let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue };
|
||||
for step in steps.iter_mut().rev() {
|
||||
let call = match step {
|
||||
Value::Array(a) => {
|
||||
let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue };
|
||||
a.get_mut(1).map(|params| (id, params))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue };
|
||||
o.get_mut("params").map(|params| (id, params))
|
||||
}
|
||||
// String steps and omitted params use `{}`, with no action target.
|
||||
_ => None,
|
||||
};
|
||||
if let Some(call) = call {
|
||||
pending.push(call);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
f(&id, params, scoped);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift numeric selectors in the session list, including calls in unscoped inline Batch steps.
|
||||
pub(crate) fn shift_action_calls(list: &mut [Action], at: usize, count: usize) {
|
||||
for action in list {
|
||||
visit_calls(&mut action.steps, &mut |id, params, _| {
|
||||
if !matches!(id, "actions.play" | "file.automate.batch") {
|
||||
return;
|
||||
}
|
||||
let Some(value) = params.get("action") else { return };
|
||||
let Some(index) = num(value).and_then(|i| usize::try_from(i).ok()) else { return };
|
||||
if index >= at {
|
||||
params["action"] = if let Some(fractional) = value.as_f64().filter(|_| value.is_f64()) {
|
||||
json!(fractional + count as f64)
|
||||
} else {
|
||||
json!(index.saturating_add(count))
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture transitive Play Action, Conditional and Batch dependencies for a standalone droplet
|
||||
/// (#2794, #6804). Ordinary references become names; normalized copies keep source-list order.
|
||||
/// A named Batch with its own action list selects an untouched source copy at an exported index,
|
||||
@@ -286,42 +346,6 @@ pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec<Act
|
||||
fn target(list: &[Action], params: &Value, id: &str) -> Result<usize> {
|
||||
if id == "file.automate.batch" { crate::file_cmds::batch_action(list, params) } else { resolve(list, params, id) }
|
||||
}
|
||||
/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched.
|
||||
fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) {
|
||||
let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect();
|
||||
while let Some((id, params)) = pending.pop() {
|
||||
let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null());
|
||||
if id == "file.automate.batch" {
|
||||
let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") };
|
||||
if let Some(key) = inline {
|
||||
if scoped {
|
||||
continue;
|
||||
}
|
||||
// A present invalid `steps` still wins over `action`, just as at runtime.
|
||||
let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue };
|
||||
for step in steps.iter_mut().rev() {
|
||||
let call = match step {
|
||||
Value::Array(a) => {
|
||||
let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue };
|
||||
a.get_mut(1).map(|params| (id, params))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue };
|
||||
o.get_mut("params").map(|params| (id, params))
|
||||
}
|
||||
// String steps and omitted params use `{}`, with no action target.
|
||||
_ => None,
|
||||
};
|
||||
if let Some(call) = call {
|
||||
pending.push(call);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
f(&id, params, scoped);
|
||||
}
|
||||
}
|
||||
/// Capture ordinary calls transitively, but retain an untouched source for scoped named Batch.
|
||||
fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut VecDeque<(usize, usize)>, scoped: &mut BTreeSet<usize>) {
|
||||
if depth > 16 {
|
||||
@@ -482,7 +506,7 @@ fn from_step(p: &Value, len: usize) -> Result<usize> {
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let actions: Vec<Value> = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect();
|
||||
let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone()));
|
||||
Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording}))
|
||||
Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -545,7 +569,32 @@ const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"];
|
||||
/// Replayable journal entries not yet committed to this action while recording.
|
||||
pub fn pending_steps(s: &Session, action: usize) -> impl Iterator<Item = &(String, Value)> {
|
||||
let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from);
|
||||
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p))
|
||||
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p) && (s.actions.record_tools || !tool_stroke(id)))
|
||||
}
|
||||
|
||||
/// The point-based painting tools committed by the canvas; fills and gradients remain commands.
|
||||
pub(crate) fn tool_stroke(id: &str) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
"paint.stroke"
|
||||
| "paint.pencil"
|
||||
| "paint.mixerBrush"
|
||||
| "paint.colorReplacement"
|
||||
| "paint.blur"
|
||||
| "paint.sharpen"
|
||||
| "paint.smudge"
|
||||
| "paint.dodge"
|
||||
| "paint.burn"
|
||||
| "paint.sponge"
|
||||
| "paint.cloneStamp"
|
||||
| "paint.healingBrush"
|
||||
| "paint.historyBrush"
|
||||
| "paint.artHistoryBrush"
|
||||
| "paint.patternStamp"
|
||||
| "paint.backgroundEraser"
|
||||
| "paint.spotHealing"
|
||||
| "paint.remove"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
|
||||
@@ -1094,7 +1143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "List Actions",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}",
|
||||
params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}",
|
||||
enabled: always,
|
||||
run: list,
|
||||
journal: false,
|
||||
|
||||
@@ -334,11 +334,11 @@ fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() {
|
||||
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back");
|
||||
|
||||
// Straighten rolls back when its crop is refused after the rotation ran.
|
||||
ruled_background(&mut s, 8);
|
||||
ruled_layer(&mut s, 8);
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) });
|
||||
s.execute("actions.record", json!({"name": "Refused"})).unwrap();
|
||||
assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err());
|
||||
assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back");
|
||||
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back");
|
||||
}
|
||||
@@ -500,23 +500,32 @@ fn ruled_background(s: &mut Session, depth: u32) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer
|
||||
/// out, so the tests that run the command target a layer and pass `crop` explicitly.
|
||||
fn ruled_layer(s: &mut Session, depth: u32) {
|
||||
ruled_background(s, depth);
|
||||
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_command_that_runs_commands_replays_it_once() {
|
||||
// Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded,
|
||||
// so playback rotates once, not three times (rotate, crop, then straighten again).
|
||||
for depth in [8, 16] {
|
||||
let mut s = Session::new();
|
||||
ruled_background(&mut s, depth);
|
||||
ruled_layer(&mut s, depth);
|
||||
s.execute("actions.record", json!({"name": "Level"})).unwrap();
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
// #12033: the crop is asked for, as automation does; the Background it used to default to
|
||||
// now greys the command out.
|
||||
s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let got = s.execute("actions.get", json!({"action": "Level"})).unwrap();
|
||||
let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}");
|
||||
let direct = s.active().unwrap().doc.clone();
|
||||
|
||||
ruled_background(&mut s, depth);
|
||||
ruled_layer(&mut s, depth);
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": "Level"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
|
||||
|
||||
@@ -13,6 +13,11 @@ use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve};
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
mod insert_path;
|
||||
mod record_again;
|
||||
pub use insert_path::selected_path as selected_recording_path;
|
||||
pub use record_again::{RecordAgainTarget, target as record_again_target};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
@@ -21,6 +26,35 @@ fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke.
|
||||
fn allow_tool_recording(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "actions.allowToolRecording";
|
||||
not_playing(s, cmd)?;
|
||||
let enabled = match p.get("enabled") {
|
||||
None | Some(Value::Null) => !s.actions.record_tools,
|
||||
Some(Value::Bool(enabled)) => *enabled,
|
||||
Some(_) => return Err(bad(cmd, "`enabled` must be true or false")),
|
||||
};
|
||||
if enabled != s.actions.record_tools {
|
||||
if s.actions.recording.is_some() && s.tool_stroke_job_running() {
|
||||
return Err(bad(cmd, "wait for the current tool stroke before changing recording"));
|
||||
}
|
||||
flush_recording(s)?;
|
||||
s.actions.record_tools = enabled;
|
||||
}
|
||||
Ok(json!({"enabled": enabled}))
|
||||
}
|
||||
|
||||
/// Insert a menu event with absent settings, reusing the ordinary validated step insertion.
|
||||
fn insert_menu_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) {
|
||||
return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings"));
|
||||
}
|
||||
let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))});
|
||||
crate::atn_cmds::menu_item_command(¶ms)?;
|
||||
s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params}))
|
||||
}
|
||||
|
||||
fn index(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
@@ -66,18 +100,113 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.actions.recording.is_some_and(|(_, i)| i == idx) {
|
||||
flush_recording(s)?;
|
||||
}
|
||||
let action = &mut s.actions.list[idx];
|
||||
action.steps.insert(at, (id.clone(), params));
|
||||
action.remap_steps(|i| Some(if i >= at { i + 1 } else { i }));
|
||||
// An inserted Stop has its dialog toggle on, so it shows its message in the app.
|
||||
if id == crate::atn_cmds::STOP_MESSAGE {
|
||||
action.dialogs.insert(at);
|
||||
let is_stop = id == crate::atn_cmds::STOP_MESSAGE;
|
||||
let result = insert_step(s, idx, at, (id, params));
|
||||
// A newly inserted Stop shows its message in the app. Duplicates keep their own flags.
|
||||
if is_stop {
|
||||
s.actions.list[idx].dialogs.insert(at);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Insert an already validated step without normalizing a copied step's payload.
|
||||
fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value {
|
||||
let action = &mut s.actions.list[idx];
|
||||
let id = step.0.clone();
|
||||
action.steps.insert(at, step);
|
||||
action.remap_steps(|i| Some(if i >= at { i + 1 } else { i }));
|
||||
let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()});
|
||||
s.actions.touch();
|
||||
result
|
||||
}
|
||||
|
||||
/// Duplicate a step or action below its source, preserving settings and per-step flags.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "actions.duplicate";
|
||||
not_playing(s, cmd)?;
|
||||
if let Some(set) = p.get("set") {
|
||||
if p.get("action").is_some() || p.get("step").is_some() {
|
||||
return Err(bad(cmd, "select a set or an action step, not both"));
|
||||
}
|
||||
return duplicate_set(s, set.as_str().ok_or_else(|| bad(cmd, "set must be a name"))?);
|
||||
}
|
||||
let idx = resolve(&s.actions.list, p, cmd)?;
|
||||
if let Some(step) = index(p, "step", cmd)? {
|
||||
let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count());
|
||||
if step >= len {
|
||||
return Err(bad(cmd, "step is out of range"));
|
||||
}
|
||||
if s.actions.recording.is_some_and(|(_, i)| i == idx) {
|
||||
flush_recording(s)?;
|
||||
}
|
||||
let action = &s.actions.list[idx];
|
||||
let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?;
|
||||
let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step));
|
||||
let at = step + 1;
|
||||
let mut result = insert_step(s, idx, at, copied);
|
||||
let action = &mut s.actions.list[idx];
|
||||
if disabled {
|
||||
action.disabled.insert(at);
|
||||
}
|
||||
if dialog {
|
||||
action.dialogs.insert(at);
|
||||
}
|
||||
result["index"] = json!(idx);
|
||||
return Ok(result);
|
||||
}
|
||||
if s.actions.recording.is_some() {
|
||||
return Err(bad(cmd, "stop recording before duplicating an action"));
|
||||
}
|
||||
let mut copied = s.actions.list[idx].clone();
|
||||
let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone()));
|
||||
copied.name = names.claim(&format!("{} copy", copied.name));
|
||||
let at = idx + 1;
|
||||
let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()});
|
||||
s.actions.list.insert(at, copied);
|
||||
crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, 1);
|
||||
s.actions.touch();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Copy the named group represented by main, including a session-only empty New Set.
|
||||
fn duplicate_set(s: &mut Session, source: &str) -> Result<Value> {
|
||||
let cmd = "actions.duplicate";
|
||||
if s.actions.recording.is_some() {
|
||||
return Err(bad(cmd, "stop recording before duplicating a set"));
|
||||
}
|
||||
let sets = set_names(s);
|
||||
if !sets.iter().any(|name| name == source) {
|
||||
return Err(bad(cmd, "the selected set no longer exists"));
|
||||
}
|
||||
let name = crate::atn_cmds::UniqueNames::new(sets).claim(&format!("{source} copy"));
|
||||
if let Some(first) = s.actions.list.iter().position(|action| action.set.as_deref() == Some(source)) {
|
||||
let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|action| action.name.clone()));
|
||||
let copies: Vec<_> = s
|
||||
.actions
|
||||
.list
|
||||
.iter()
|
||||
.filter(|action| action.set.as_deref() == Some(source))
|
||||
.cloned()
|
||||
.map(|mut action| {
|
||||
action.name = names.claim(&format!("{} copy", action.name));
|
||||
action.set = Some(name.clone());
|
||||
action
|
||||
})
|
||||
.collect();
|
||||
// Classic groups rows by name even when a set's actions are interleaved.
|
||||
let at = first + s.actions.list[first..].iter().take_while(|action| action.set.as_deref() == Some(source)).count();
|
||||
let count = copies.len();
|
||||
s.actions.list.splice(at..at, copies);
|
||||
crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, count);
|
||||
s.actions.touch();
|
||||
return Ok(json!({"set": name, "index": at, "actions": count}));
|
||||
}
|
||||
let at = s.actions.sets.iter().position(|set| set == source).ok_or_else(|| bad(cmd, "the selected set no longer exists"))? + 1;
|
||||
s.actions.sets.insert(at, name.clone());
|
||||
s.actions.touch();
|
||||
Ok(json!({"set": name, "actions": 0}))
|
||||
}
|
||||
|
||||
/// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without
|
||||
/// it, every step (all on unless all already are, like clicking the action's own toggle).
|
||||
fn dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -277,6 +406,56 @@ fn conditional(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "actions.insertMenuItem",
|
||||
label: "Insert Menu Item…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##,
|
||||
enabled: always,
|
||||
run: insert_menu_item,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.insertPath",
|
||||
label: "Insert Path",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. The measured Work Path ATN descriptor supports absolute coordinates, cubic handles, even-odd subpaths and Add/Subtract/XOR. Paths outside that measured form still play natively and are reported as unsupported by ATN export."##,
|
||||
enabled: always,
|
||||
run: insert_path::run,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.allowToolRecording",
|
||||
label: "Allow Tool Recording",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##,
|
||||
enabled: always,
|
||||
run: allow_tool_recording,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.recordAgain",
|
||||
label: "Record Again",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##,
|
||||
enabled: always,
|
||||
run: record_again::run,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.duplicate",
|
||||
label: "Duplicate Action",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index?,step?,steps?,set?,actions?}. Copy an action, step or named set below the source. Copies retain settings and flags, and leave shortcuts with the originals."##,
|
||||
enabled: always,
|
||||
run: duplicate,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.insert",
|
||||
label: "Insert Step",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Capture full native geometry without substituting another path for a stale selection.
|
||||
use super::{Result, Session, Value, bad, json};
|
||||
use photocraft_doc::{LayerContent, Path};
|
||||
|
||||
/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback.
|
||||
pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> {
|
||||
let st = s.active().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = match name {
|
||||
"work" => st.doc.work_path.as_ref(),
|
||||
"layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content {
|
||||
LayerContent::Shape(shape) => Some(&shape.path),
|
||||
_ => l.vector_mask.as_ref().map(|v| &v.path),
|
||||
}),
|
||||
name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path),
|
||||
};
|
||||
path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists"))
|
||||
}
|
||||
|
||||
/// The native path step is also the source for a future independently verified ATN wire mapping.
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?;
|
||||
let path = selected_path(s, name)?;
|
||||
photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?;
|
||||
let path = crate::vector_cmds::path_json(path);
|
||||
s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}}))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Record Again executes the selected command before replacing its stored settings.
|
||||
use super::{Result, Session, Value, bad, index, json, not_playing, resolve};
|
||||
|
||||
const COMMAND: &str = "actions.recordAgain";
|
||||
|
||||
/// A validated selection shared by the engine and the panel's seeded editor.
|
||||
pub struct RecordAgainTarget {
|
||||
pub action: usize,
|
||||
pub step: usize,
|
||||
pub command: String,
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// Resolve only executable steps, without flushing recording or editing the library.
|
||||
pub fn target(s: &Session, p: &Value) -> Result<RecordAgainTarget> {
|
||||
not_playing(s, COMMAND)?;
|
||||
if s.actions.recording.is_some() {
|
||||
return Err(bad(COMMAND, "stop recording before recording a step again"));
|
||||
}
|
||||
if let Some(revision) = p.get("revision") {
|
||||
let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?;
|
||||
if revision != s.actions.rev {
|
||||
return Err(bad(COMMAND, "the actions changed; select the step again"));
|
||||
}
|
||||
}
|
||||
let action = resolve(&s.actions.list, p, COMMAND)?;
|
||||
let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?;
|
||||
let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?;
|
||||
let (command, params) =
|
||||
if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) };
|
||||
if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) {
|
||||
return Err(bad(COMMAND, "the selected step has no editable engine command"));
|
||||
}
|
||||
Ok(RecordAgainTarget { action, step, command, params })
|
||||
}
|
||||
|
||||
/// Keep the original definition on invalid settings, denied execution or changed ownership.
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let selected = target(s, p)?;
|
||||
let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?;
|
||||
let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?;
|
||||
let revision = s.actions.rev;
|
||||
// Resolve portable recorded targets for execution; keep their portable form in the step.
|
||||
let resolved = crate::actions_cmds::playback_params(s, &selected.command, ¶ms)?;
|
||||
let result = s.execute(&selected.command, resolved)?;
|
||||
if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) {
|
||||
return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten"));
|
||||
}
|
||||
let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?;
|
||||
let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?;
|
||||
*step = (selected.command.clone(), params);
|
||||
let name = action.name.clone();
|
||||
s.actions.touch();
|
||||
Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result}))
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
//! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value
|
||||
//! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean.
|
||||
//! Lengths and areas are scaled by the measurement scale.
|
||||
//!
|
||||
//! Straighten Layer is offered where the reference application offers it: the ruler line is needed,
|
||||
//! and the target must be a single layer that is not the Background, not a group and not locked in
|
||||
//! place ([`straighten_disabled`], #12033). A hidden target layer keeps the button and is refused
|
||||
//! with [`STRAIGHTEN_HIDDEN_ERROR`] before anything changes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -138,6 +143,60 @@ fn has_ruler(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into())
|
||||
}
|
||||
|
||||
/// The reference's alert for Straighten Layer on a hidden target layer (#12033), measured: one
|
||||
/// alert window with an OK button, and the document and its History stay as they were. The button
|
||||
/// is *enabled* for a hidden layer, so this is the command's refusal and not a greyed state; the
|
||||
/// options bar shows the same text through `tl!` (English is the catalog source).
|
||||
pub const STRAIGHTEN_HIDDEN_ERROR: &str = "Could not straighten the layer because the target layer is hidden.";
|
||||
|
||||
/// The enabled check of `image.analysis.straightenLayer`: a ruler line, and a target the reference
|
||||
/// straightens ([`straighten_disabled`], #12033).
|
||||
fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_ruler(s)?;
|
||||
straighten_disabled(s).map_or(Ok(()), |why| Err(why.into()))
|
||||
}
|
||||
|
||||
/// Why Straighten Layer is greyed out for the current target, `None` when its button is live
|
||||
/// (#12033). Measured with a ruler line drawn: the Background, two or more selected layers, a
|
||||
/// group, a layer with Lock All and a layer with Lock Position all grey it out.
|
||||
///
|
||||
/// Deliberately not here: a hidden target layer keeps the button enabled and is refused by
|
||||
/// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured*
|
||||
/// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other
|
||||
/// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps
|
||||
/// turning the whole canvas as it does now; an active id the document no longer holds is refused.
|
||||
fn straighten_disabled(s: &Session) -> Option<&'static str> {
|
||||
let st = s.active()?;
|
||||
if st.selected_layers().len() > 1 {
|
||||
return Some("several layers selected");
|
||||
}
|
||||
// The active layer is the one the Layers panel targets; with none, the command turns the
|
||||
// canvas, which the reference was not measured for.
|
||||
let id = st.active_layer?;
|
||||
// An id the document no longer holds: `Session::edit` keeps the *selected* layers valid but
|
||||
// not the active one, so an edit that drops the active layer can leave a stale id behind.
|
||||
// Turning and cropping the whole canvas for a layer that is not there is the worse answer, and
|
||||
// `select_layer` and `restore_layer_selection` both refuse such an id.
|
||||
let Some(layer) = st.doc.layer(id) else {
|
||||
return Some("no active layer");
|
||||
};
|
||||
if st.doc.is_background(id) {
|
||||
return Some("the Background");
|
||||
}
|
||||
if layer.is_group() {
|
||||
return Some("a group");
|
||||
}
|
||||
// Locks inherited from a group count, as they do for the Move tool.
|
||||
let locks = st.doc.effective_locks(id);
|
||||
if locks.all {
|
||||
return Some("the layer is locked");
|
||||
}
|
||||
if locks.position {
|
||||
return Some("the layer's position is locked");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_log(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -718,6 +777,12 @@ pub fn straighten_angle(r: &Ruler) -> f64 {
|
||||
fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = doc(s)?;
|
||||
let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?;
|
||||
// A hidden target layer keeps its button and gets this alert instead of a rotation (#12033).
|
||||
// Asked before anything is read for the edit, so the document, its History and the ruler line
|
||||
// stay exactly as they were; a layer inside a hidden group is hidden too (#571).
|
||||
if s.active().and_then(|st| st.active_layer).is_some_and(|id| d.path_of(id).is_some_and(|path| !d.shown_at(&path))) {
|
||||
return Err(EngineError::Other(STRAIGHTEN_HIDDEN_ERROR.into()));
|
||||
}
|
||||
let rot = straighten_angle(&r);
|
||||
if rot.abs() < 1e-9 {
|
||||
return Ok(json!({"angle": 0.0}));
|
||||
@@ -796,7 +861,7 @@ fn count_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if gi.is_some_and(|i| i >= group_count) {
|
||||
return Err(bad("count.add", "no such group"));
|
||||
}
|
||||
let n = s.edit("Count", |d, _| {
|
||||
let n = s.edit("Add Count Object", |d, _| {
|
||||
if d.measurement.count_groups.is_empty() {
|
||||
let g = new_group(d, None, None);
|
||||
d.measurement.count_groups.push(g);
|
||||
@@ -846,7 +911,7 @@ fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
|
||||
fn count_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (g, i) = locate(s, p, "count.remove")?;
|
||||
s.edit("Count", |d, _| {
|
||||
s.edit("Delete Count Object", |d, _| {
|
||||
d.measurement.count_groups[g].points.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -856,7 +921,7 @@ fn count_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn count_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?;
|
||||
let (g, i) = locate(s, p, "count.move")?;
|
||||
s.edit("Count", |d, _| {
|
||||
s.edit("Drag Count Object", |d, _| {
|
||||
d.measurement.count_groups[g].points[i] = to;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -881,7 +946,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn count_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
let color = color_of("count.newGroup", p, "color")?;
|
||||
s.edit("New Count Group", |d, _| {
|
||||
s.edit("Add Count Group", |d, _| {
|
||||
let g = new_group(d, name, color);
|
||||
d.measurement.count_groups.push(g);
|
||||
d.measurement.active_count_group = d.measurement.count_groups.len() - 1;
|
||||
@@ -949,8 +1014,24 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group"));
|
||||
let changed = grp != d.measurement.count_groups[g];
|
||||
if changed || (activate && d.measurement.active_count_group != g) {
|
||||
s.edit("Count Group Options", |d, _| {
|
||||
let switches = activate && d.measurement.active_count_group != g;
|
||||
if changed || switches {
|
||||
// One field changed: Photoshop's name for it (#11763). Several fields, or a switch of the
|
||||
// current group (not measured), keep "Count Group Options".
|
||||
let old = &d.measurement.count_groups[g];
|
||||
let fields = [
|
||||
(grp.name != old.name, "Set Count Group Name"),
|
||||
(grp.visible != old.visible, "Set Count Group Visibility"),
|
||||
(grp.color != old.color, "Set Count Draw Color"),
|
||||
(grp.marker_size != old.marker_size, "Set Count Marker Size"),
|
||||
(grp.label_size != old.label_size, "Set Count Font Size"),
|
||||
];
|
||||
let mut one = fields.iter().filter(|(c, _)| *c).map(|(_, l)| *l);
|
||||
let label = match (one.next(), one.next()) {
|
||||
(Some(l), None) if !switches => l,
|
||||
_ => "Count Group Options",
|
||||
};
|
||||
s.edit(label, |d, _| {
|
||||
d.measurement.count_groups[g] = grp;
|
||||
if activate {
|
||||
d.measurement.active_count_group = g;
|
||||
@@ -1297,8 +1378,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.analysis.straightenLayer",
|
||||
"Straighten Layer",
|
||||
[],
|
||||
r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##,
|
||||
has_ruler,
|
||||
r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image; off for the Background, two or more selected layers, a group, Lock All and Lock Position, and a hidden target layer answers "Could not straighten the layer because the target layer is hidden." with nothing changed (#12033))"##,
|
||||
has_straightenable_layer,
|
||||
straighten,
|
||||
true
|
||||
),
|
||||
@@ -1332,5 +1413,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "analysis_straighten_enabled_tests.rs"]
|
||||
mod analysis_straighten_enabled_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -231,10 +231,13 @@ fn straighten_layer_and_image() {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step");
|
||||
assert_eq!(d(&s).size.width, 80);
|
||||
assert!(d(&s).measurement.ruler.is_none());
|
||||
// The Background: rotate the canvas and crop to the image.
|
||||
// The Background used to rotate the canvas and crop it to the image; #12033 greys that state
|
||||
// out, so the same crop now happens on a layer and only when it is asked for.
|
||||
let mut s = session(80, 60, 16);
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap();
|
||||
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("image.analysis.straightenLayer"), "the Background is greyed out (#12033)");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let r = s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap();
|
||||
assert_eq!(r["cropped"], true);
|
||||
assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0);
|
||||
let nd = d(&s);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Straighten Layer's enabled states and its hidden-layer refusal, measured against the reference
|
||||
//! application (#12033).
|
||||
//!
|
||||
//! With a ruler line drawn the reference greys the button out for the Background, two or more
|
||||
//! selected layers, a group, a layer with Lock All and a layer with Lock Position. A hidden target
|
||||
//! layer keeps the button enabled: the click answers an alert ("Could not straighten the layer
|
||||
//! because the target layer is hidden.") and nothing changes. Pixel, text and smart object layers
|
||||
//! stay enabled and are rotated.
|
||||
//!
|
||||
//! A layer with a mask, a shape or vector layer, an adjustment layer, artboards and the other lock
|
||||
//! kinds are *not measured* there: they keep today's behaviour, so the tests below only pin that
|
||||
//! the new check does not reach them.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
|
||||
use super::*;
|
||||
|
||||
const CMD: &str = "image.analysis.straightenLayer";
|
||||
|
||||
/// A white document with a Background, as File > New makes one.
|
||||
fn session(w: u32, h: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The same document in CMYK at 16 bits, which the reference also straightens.
|
||||
fn cmyk_16(w: u32, h: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "mode": "cmyk", "depth": 16, "background": "white"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A ruler line to straighten by: without one the command is off for every target.
|
||||
fn rule(s: &mut Session) {
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
}
|
||||
|
||||
/// A block of opaque pixels on the layer: an empty layer has nothing to transform, which would
|
||||
/// hide the state under test behind "nothing to transform". The colour has the layer's own channel
|
||||
/// count, so it works for RGB, Grayscale and CMYK at 8 and 16 bits alike.
|
||||
fn paint(s: &mut Session, l: LayerId) {
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layer_mut(l).unwrap().surface_mut().unwrap();
|
||||
let mut px = vec![0.0; surf.channels()];
|
||||
if let Some(first) = px.first_mut() {
|
||||
*first = 1.0;
|
||||
}
|
||||
if let Some(last) = px.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
surf.fill_rect(Rect::new(10, 10, 50, 20), &px);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A new pixel layer with something on it, which `layer.new.layer` makes the active one.
|
||||
fn pixel_layer(s: &mut Session, name: &str) -> LayerId {
|
||||
let l = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap());
|
||||
paint(s, l);
|
||||
l
|
||||
}
|
||||
|
||||
/// A type layer over the canvas.
|
||||
fn text_layer(s: &mut Session) -> LayerId {
|
||||
LayerId(s.execute("type.create", json!({"x": 40, "y": 50, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A pixel layer turned into a smart object.
|
||||
fn smart_layer(s: &mut Session) -> LayerId {
|
||||
let l = pixel_layer(s, "Smart");
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": l.0})).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
fn doc_of(s: &Session) -> Arc<Document> {
|
||||
s.active().unwrap().doc.clone()
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
/// A state the reference greys the button out for: the engine says the command is off, so the menu
|
||||
/// item and the options-bar button follow, and a call answered anyway (the CLI, the control channel,
|
||||
/// an action step) is refused as a disabled command rather than run. Nothing is touched: the
|
||||
/// document is the same `Arc`, no History step is added and the ruler line stays.
|
||||
fn assert_greyed(s: &mut Session, what: &str) {
|
||||
let (before, history) = (doc_of(s), steps(s));
|
||||
assert!(!s.is_enabled(CMD), "{what}: the button is greyed out");
|
||||
let e = s.execute(CMD, json!({})).unwrap_err();
|
||||
let disabled = matches!(&e, EngineError::Disabled(id, _) if id == CMD);
|
||||
assert!(disabled, "{what}: refused as a disabled command, got {e}");
|
||||
assert!(Arc::ptr_eq(&before, &doc_of(s)), "{what}: the document was not touched");
|
||||
assert_eq!(steps(s), history, "{what}: no History step");
|
||||
assert!(doc_of(s).measurement.ruler.is_some(), "{what}: the ruler line is still there");
|
||||
}
|
||||
|
||||
/// The angle the ruler line of [`rule`] straightens by.
|
||||
const ROT: f64 = 4.7636;
|
||||
|
||||
#[test]
|
||||
fn the_background_greys_straighten_layer_out() {
|
||||
// Today it rotates the canvas and crops it (800 x 600 becomes 500 x 375); the reference has no
|
||||
// button for it at all.
|
||||
let mut s = session(100, 80);
|
||||
assert!(doc_of(&s).is_background(s.active().unwrap().active_layer.unwrap()), "the Background is the target");
|
||||
rule(&mut s);
|
||||
assert_greyed(&mut s, "the Background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_selected_layers_grey_straighten_layer_out() {
|
||||
let mut s = session(100, 80);
|
||||
let a = pixel_layer(&mut s, "One");
|
||||
let b = pixel_layer(&mut s, "Two");
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected");
|
||||
rule(&mut s);
|
||||
assert_greyed(&mut s, "two selected layers");
|
||||
// Back to one layer, the button is live again.
|
||||
s.execute("layer.select", json!({"layer": b.0})).unwrap();
|
||||
assert!(s.is_enabled(CMD), "one selected layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_greys_straighten_layer_out() {
|
||||
let mut s = session(100, 80);
|
||||
pixel_layer(&mut s, "Inner");
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let g = s.active().unwrap().active_layer.unwrap();
|
||||
assert!(doc_of(&s).layer(g).unwrap().is_group(), "the new group is the target");
|
||||
rule(&mut s);
|
||||
assert_greyed(&mut s, "a group");
|
||||
// A layer inside the group is a layer, so the button is on for it.
|
||||
let inner = doc_of(&s).layer(g).unwrap().children().unwrap()[0].id;
|
||||
s.execute("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
assert!(s.is_enabled(CMD), "a layer in a group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_all_and_lock_position_grey_straighten_layer_out() {
|
||||
// Measured: both grey the button. Today it is enabled and the command fails with
|
||||
// `layer "Colors" is locked`, which is what a greyed button cannot do any more.
|
||||
for key in ["all", "position"] {
|
||||
let mut s = session(100, 80);
|
||||
let l = pixel_layer(&mut s, "Colors");
|
||||
s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap();
|
||||
rule(&mut s);
|
||||
assert_greyed(&mut s, &format!("Lock {}", if key == "all" { "All" } else { "Position" }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_other_lock_kinds_are_not_reached_by_the_new_check() {
|
||||
// Not measured in the reference, so they keep today's behaviour: the button stays on and the
|
||||
// command answers as it does now.
|
||||
for key in ["transparency", "pixels", "artboard"] {
|
||||
let mut s = session(100, 80);
|
||||
let l = pixel_layer(&mut s, "Colors");
|
||||
s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap();
|
||||
rule(&mut s);
|
||||
assert!(s.is_enabled(CMD), "Lock {key}: not measured, still enabled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_layer_stays_enabled_and_is_refused_with_the_alert() {
|
||||
// The one measured refusal: the button is on, the click shows the alert and nothing changes.
|
||||
let mut s = session(100, 80);
|
||||
let l = pixel_layer(&mut s, "Gone");
|
||||
s.execute("layer.setProps", json!({"layer": l.0, "visible": false})).unwrap();
|
||||
rule(&mut s);
|
||||
let (before, history) = (doc_of(&s), steps(&s));
|
||||
assert!(s.is_enabled(CMD), "a hidden layer keeps the button on");
|
||||
let e = s.execute(CMD, json!({})).unwrap_err();
|
||||
assert_eq!(e.to_string(), STRAIGHTEN_HIDDEN_ERROR, "the reference's alert text");
|
||||
assert!(matches!(e, EngineError::Other(_)), "an alert, not a greyed state: {e}");
|
||||
assert!(Arc::ptr_eq(&before, &doc_of(&s)), "nothing changed");
|
||||
assert_eq!(steps(&s), history, "no History step");
|
||||
assert!(doc_of(&s).measurement.ruler.is_some(), "a straighten that never ran leaves the ruler line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_hidden_with_its_group_is_refused_the_same_way() {
|
||||
// A layer inside a hidden group is hidden, as for the painting tools (#571).
|
||||
let mut s = session(100, 80);
|
||||
let l = pixel_layer(&mut s, "Inner");
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let g = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": g.0, "visible": false})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": l.0})).unwrap();
|
||||
rule(&mut s);
|
||||
let (before, history) = (doc_of(&s), steps(&s));
|
||||
assert!(s.is_enabled(CMD), "the target is a layer, not the group");
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap_err().to_string(), STRAIGHTEN_HIDDEN_ERROR);
|
||||
assert!(Arc::ptr_eq(&before, &doc_of(&s)));
|
||||
assert_eq!(steps(&s), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() {
|
||||
let mut s = session(100, 80);
|
||||
pixel_layer(&mut s, "Paint");
|
||||
assert!(!s.is_enabled(CMD), "no ruler line");
|
||||
s.execute("layer.setProps", json!({"layer": s.active().unwrap().active_layer.unwrap().0, "visible": false})).unwrap();
|
||||
assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_layer_the_document_no_longer_holds_is_refused() {
|
||||
// `Session::edit` prunes the selected layers but not the active id, so an edit that drops the
|
||||
// active layer can leave one behind (`select_layer` and `restore_layer_selection` both refuse
|
||||
// such an id). The command must then say it is off, not rotate and crop the whole canvas for a
|
||||
// layer that is not there.
|
||||
let mut s = session(100, 80);
|
||||
let l = pixel_layer(&mut s, "Gone");
|
||||
s.edit("drop", |doc, _| {
|
||||
doc.layers.retain(|layer| layer.id != l);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(l), "the target still points at the dropped layer");
|
||||
assert!(doc_of(&s).layer(l).is_none(), "and the document does not hold it");
|
||||
rule(&mut s);
|
||||
assert_greyed(&mut s, "an active layer that is gone");
|
||||
// The destructive case this closes: `crop` asks for the canvas, which would turn and shrink for
|
||||
// a target that no longer exists.
|
||||
let size = (doc_of(&s).size.width, doc_of(&s).size.height);
|
||||
assert!(s.execute(CMD, json!({"crop": true})).is_err());
|
||||
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas kept its size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_visible_layer_still_rotates_without_cropping() {
|
||||
// The pin for the state that stays: the layer turns about the canvas centre, the canvas does not.
|
||||
let mut s = session(100, 80);
|
||||
pixel_layer(&mut s, "Paint");
|
||||
rule(&mut s);
|
||||
let (history, size) = (steps(&s), (doc_of(&s).size.width, doc_of(&s).size.height));
|
||||
assert!(s.is_enabled(CMD), "a layer is what the button is for");
|
||||
let r = s.execute(CMD, json!({})).unwrap();
|
||||
assert_eq!(r["cropped"], false, "{r}");
|
||||
assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{r}");
|
||||
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas keeps its size");
|
||||
assert_eq!(steps(&s), history + 1, "one History step");
|
||||
assert!(doc_of(&s).measurement.ruler.is_none(), "the ruler line is cleared");
|
||||
assert!(s.undo());
|
||||
assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "and one Undo takes it back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_text_and_smart_object_layers_stay_enabled_and_rotate() {
|
||||
// Measured, also in a CMYK 16-bit document.
|
||||
type Maker = (&'static str, fn(&mut Session) -> LayerId);
|
||||
let make: [Maker; 3] = [("pixel", |s| pixel_layer(s, "Paint")), ("type", text_layer), ("smart object", smart_layer)];
|
||||
for (what, make_layer) in make {
|
||||
for new in [session as fn(u32, u32) -> Session, cmyk_16] {
|
||||
let mut s = new(100, 80);
|
||||
make_layer(&mut s);
|
||||
rule(&mut s);
|
||||
assert!(s.is_enabled(CMD), "{what}: enabled");
|
||||
let r = s.execute(CMD, json!({}));
|
||||
let r = r.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(r["cropped"], false, "{what}: rotated, not cropped");
|
||||
assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{what}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_crop_still_crops_for_automation() {
|
||||
// The `crop` param stays available to automation where the command is enabled.
|
||||
let mut s = session(100, 80);
|
||||
pixel_layer(&mut s, "Paint");
|
||||
rule(&mut s);
|
||||
let r = s.execute(CMD, json!({"crop": true})).unwrap();
|
||||
assert_eq!(r["cropped"], true, "{r}");
|
||||
let d = doc_of(&s);
|
||||
assert!(d.size.width < 100 && d.size.height < 80, "the canvas is rotated and cropped to {:?}", [d.size.width, d.size.height]);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod descriptors;
|
||||
mod events;
|
||||
mod fields;
|
||||
mod files;
|
||||
mod menu_item;
|
||||
|
||||
mod steps_artboard;
|
||||
mod steps_batch;
|
||||
@@ -41,6 +42,7 @@ mod steps_content_aware;
|
||||
mod steps_direct_adjustments;
|
||||
mod steps_distort;
|
||||
mod steps_doc;
|
||||
mod steps_droplet;
|
||||
mod steps_gallery;
|
||||
mod steps_gradient;
|
||||
mod steps_image;
|
||||
@@ -48,6 +50,7 @@ mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_lookup;
|
||||
mod steps_make;
|
||||
mod steps_path;
|
||||
mod steps_perspective_warp;
|
||||
mod steps_type_path;
|
||||
|
||||
@@ -62,6 +65,7 @@ mod tables;
|
||||
|
||||
#[cfg(test)]
|
||||
use adjustments::{levels_from, selective_from};
|
||||
pub(crate) use convert::UniqueNames;
|
||||
pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key};
|
||||
#[cfg(test)]
|
||||
use convert::{MAX_NAME_CHARS, export_item, normalise};
|
||||
@@ -76,6 +80,7 @@ use events::{EVENTS, event_and_descriptor, event_for};
|
||||
#[cfg(test)]
|
||||
use fields::Mapped;
|
||||
use files::{load, save};
|
||||
pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings};
|
||||
#[cfg(test)]
|
||||
use steps_doc::crop_from;
|
||||
pub(crate) use steps_gallery::RECORDED_EVENT;
|
||||
@@ -150,6 +155,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: stop_message,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: MENU_ITEM,
|
||||
label: "Menu Item",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##,
|
||||
enabled: always,
|
||||
run: menu_item::run,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: PHOTOSHOP_STEP,
|
||||
label: "Photoshop Step",
|
||||
@@ -177,3 +192,6 @@ mod selection_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod recorded_tools_tests;
|
||||
@@ -69,10 +69,12 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve
|
||||
let mut action = Action::new(name.clone(), Vec::new());
|
||||
action.set = Some(clip_name(set));
|
||||
for (i, item) in a.items.iter().enumerate() {
|
||||
let mapped = match (event_for(&item.event), &item.descriptor) {
|
||||
(Some(e), Some(d)) => (e.from)(d),
|
||||
(Some(e), None) => (e.from)(&Descriptor::default()),
|
||||
(None, _) => Mapped::Unsupported { harmless: false },
|
||||
let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten();
|
||||
let mapped = match (menu, event_for(&item.event), &item.descriptor) {
|
||||
(Some(params), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), params)]),
|
||||
(_, Some(e), Some(d)) => (e.from)(d),
|
||||
(_, Some(e), None) => (e.from)(&Descriptor::default()),
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
let (new_steps, enabled) = match mapped {
|
||||
Mapped::Steps(mut s) => {
|
||||
@@ -136,6 +138,9 @@ pub struct Skipped {
|
||||
}
|
||||
|
||||
pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
|
||||
if id == super::MENU_ITEM {
|
||||
return super::menu_item::export(params);
|
||||
}
|
||||
if id == PHOTOSHOP_STEP {
|
||||
let event = jstr(params, "event")?;
|
||||
let event = match params.get("eventKind") {
|
||||
@@ -166,7 +171,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem
|
||||
{
|
||||
return Some(AtnItem { event, dictionary: name, descriptor: Some(descriptor), ..Default::default() });
|
||||
}
|
||||
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
let event = if id == "path.set" || e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
let omit_descriptor = super::steps_stack::without_descriptor(id, ¶ms) || super::steps_video::omitted(id, ¶ms);
|
||||
let descriptor = (!omit_descriptor).then_some(descriptor);
|
||||
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor, ..Default::default() })
|
||||
@@ -268,18 +273,18 @@ pub fn export(
|
||||
/// Assigns names not already used, as `name`, `name 2`, `name 3`… A plain linear scan of the
|
||||
/// taken list per candidate is cubic when an action file repeats one base name many times, so
|
||||
/// membership uses a set and the next suffix to try is cached per base name.
|
||||
pub(super) struct UniqueNames {
|
||||
pub(crate) struct UniqueNames {
|
||||
taken: std::collections::HashSet<String>,
|
||||
next_suffix: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl UniqueNames {
|
||||
pub(super) fn new<I: IntoIterator<Item = String>>(existing: I) -> Self {
|
||||
pub(crate) fn new<I: IntoIterator<Item = String>>(existing: I) -> Self {
|
||||
UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() }
|
||||
}
|
||||
|
||||
/// Reserves and returns a unique name derived from `name`, recording it as taken.
|
||||
pub(super) fn claim(&mut self, name: &str) -> String {
|
||||
pub(crate) fn claim(&mut self, name: &str) -> String {
|
||||
if self.taken.insert(name.to_string()) {
|
||||
return name.to_string();
|
||||
}
|
||||
|
||||
@@ -910,6 +910,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
"layer.new.layerFromBackground",
|
||||
"layer.layerStyle.blendingOptions",
|
||||
"layer.smartFilter.blendingOptions",
|
||||
"path.set",
|
||||
],
|
||||
from: set_from,
|
||||
to: set_to,
|
||||
@@ -1199,6 +1200,15 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
from: super::steps_batch::batch_from,
|
||||
to: super::steps_batch::batch_to,
|
||||
},
|
||||
// Create Droplet (#10710): Photoshop has no string ID for it.
|
||||
Event {
|
||||
code: "CrtD",
|
||||
name: "CrtD",
|
||||
label: "Create Droplet",
|
||||
ids: &["file.automate.createDroplet"],
|
||||
from: super::steps_droplet::droplet_from,
|
||||
to: super::steps_droplet::droplet_to,
|
||||
},
|
||||
Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to },
|
||||
Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to },
|
||||
];
|
||||
|
||||
@@ -51,21 +51,22 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
names.push(name);
|
||||
added.push(a);
|
||||
}
|
||||
// Play, Conditional and Batch steps call actions by name: follow the renames in the actions just
|
||||
// loaded, each target looked up once by the name the file gave it.
|
||||
// Play, Conditional, Batch and Create Droplet steps call actions by name: follow the renames in
|
||||
// the actions just loaded, each target looked up once by the name the file gave it.
|
||||
for a in &mut added {
|
||||
for (id, params) in &mut a.steps {
|
||||
// A qualified Batch reference belongs to the file's original set, not to a
|
||||
// foreign set which happens to have the destination override's name.
|
||||
if id == "file.automate.batch" && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) {
|
||||
// A qualified Batch or Create Droplet reference belongs to the file's original set, not
|
||||
// to a foreign set which happens to have the destination override's name.
|
||||
let by_set = matches!(id.as_str(), "file.automate.batch" | "file.automate.createDroplet");
|
||||
if by_set && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) {
|
||||
params["set"] = json!(imp.set);
|
||||
} else if id == "file.automate.batch" && params.get("set").is_some_and(|set| !set.is_null()) {
|
||||
} else if by_set && params.get("set").is_some_and(|set| !set.is_null()) {
|
||||
continue;
|
||||
}
|
||||
let keys: &[&str] = match id.as_str() {
|
||||
"actions.play" => &["action"],
|
||||
"actions.conditional" => &["then", "else"],
|
||||
"file.automate.batch" => &["action"],
|
||||
"file.automate.batch" | "file.automate.createDroplet" => &["action"],
|
||||
_ => &[],
|
||||
};
|
||||
for key in keys {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Descriptor-free menu intent is distinct from a command with recorded settings.
|
||||
//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones.
|
||||
|
||||
use super::events::{EVENTS, Event, event_for};
|
||||
use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json};
|
||||
|
||||
/// Find a supported, unambiguous event for a menu step with no settings.
|
||||
fn menu_event(id: &str) -> Option<&'static Event> {
|
||||
// Camera Raw's ATN mapping requires a verified settings descriptor (#8714).
|
||||
if id == crate::lens_cmds::RAW || (id != "image.imageSize" && !id.starts_with("filter.")) {
|
||||
return None;
|
||||
}
|
||||
EVENTS.iter().find(|e| {
|
||||
e.ids == [id]
|
||||
&& !e.name.is_empty()
|
||||
&& event_for(&AtnEvent::name(e.name)).is_some_and(|mapped| mapped.code == e.code && mapped.ids == e.ids)
|
||||
&& crate::commands::find(id).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Menu choices use existing event mappings rather than inventing event names from command ids.
|
||||
pub fn menu_item_commands() -> Vec<&'static str> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect()
|
||||
}
|
||||
|
||||
/// Stateless filter menu items execute immediately; settings-bearing ones require the editor.
|
||||
pub fn menu_item_needs_settings(id: &str) -> bool {
|
||||
crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}"))
|
||||
}
|
||||
|
||||
/// An Insert Menu Item step with no recorded command descriptor.
|
||||
pub const MENU_ITEM: &str = "actions.menuItem";
|
||||
|
||||
/// Validate menu identity before the shell gates and opens its actual command.
|
||||
pub fn menu_item_command(p: &Value) -> Result<&str> {
|
||||
let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?;
|
||||
if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) {
|
||||
return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings"));
|
||||
}
|
||||
if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) {
|
||||
return Err(bad(MENU_ITEM, "`eventKind` must be name or code"));
|
||||
}
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?;
|
||||
let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?;
|
||||
if p.get("eventKind").and_then(Value::as_str) == Some("code") {
|
||||
let code = event.code.as_bytes().try_into().ok().map(AtnEvent::Code);
|
||||
if !code.as_ref().and_then(event_for).is_some_and(|mapped| mapped.name == event.name && mapped.ids == event.ids) {
|
||||
return Err(bad(MENU_ITEM, "this menu event has no unambiguous four-character code"));
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Preserve the exact code/name kind only for the supported descriptor-free event.
|
||||
pub(super) fn from_event(event: &AtnEvent) -> Option<Value> {
|
||||
let mapped = event_for(event)?;
|
||||
let id = *mapped.ids.first()?;
|
||||
let canonical = menu_event(id)?;
|
||||
if canonical.name != mapped.name || canonical.code != mapped.code {
|
||||
return None;
|
||||
}
|
||||
let kind = match event {
|
||||
AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code",
|
||||
AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name",
|
||||
_ => return None,
|
||||
};
|
||||
Some(json!({"id":id,"eventKind":kind}))
|
||||
}
|
||||
|
||||
/// Write absent settings as `None`, never as an empty or current-target descriptor.
|
||||
pub(super) fn export(p: &Value) -> Option<AtnItem> {
|
||||
let mapped = menu_event(menu_item_command(p).ok()?)?;
|
||||
let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") {
|
||||
AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?)
|
||||
} else {
|
||||
AtnEvent::name(mapped.name)
|
||||
};
|
||||
let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec()));
|
||||
Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Headless execution runs stateless items and refuses settings deliberately left for a dialog.
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = menu_item_command(p)?;
|
||||
if !menu_item_needs_settings(id) {
|
||||
return s.execute(id, json!({}));
|
||||
}
|
||||
Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied")))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn};
|
||||
use super::*;
|
||||
|
||||
/// Construct a pixel point directly from the measured #10706 wire format.
|
||||
fn wire_point(x: f64, y: f64) -> D {
|
||||
D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y }))
|
||||
}
|
||||
|
||||
/// Encode a fixture corner without cubic handles.
|
||||
fn corner(x: f64, y: f64) -> D {
|
||||
D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y)))
|
||||
}
|
||||
|
||||
/// Build the measured outer and inner contours independently of the exporter.
|
||||
fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor {
|
||||
let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)];
|
||||
if !joined {
|
||||
outer[1] = D::Descriptor(
|
||||
Descriptor::new("Pthp")
|
||||
.with("Anch", wire_point(50.0, 100.0))
|
||||
.with("Fwd ", wire_point(50.0, 100.0))
|
||||
.with("Bwd ", wire_point(100.0, 20.0))
|
||||
.with("Smoo", D::Boolean(false)),
|
||||
);
|
||||
outer[2] = D::Descriptor(
|
||||
Descriptor::new("Pthp")
|
||||
.with("Anch", wire_point(350.0, 100.0))
|
||||
.with("Fwd ", wire_point(300.0, 20.0))
|
||||
.with("Bwd ", wire_point(350.0, 100.0))
|
||||
.with("Smoo", D::Boolean(false)),
|
||||
);
|
||||
}
|
||||
let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)];
|
||||
if reversed {
|
||||
inner.reverse();
|
||||
}
|
||||
let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points)));
|
||||
let component = |op, subs| {
|
||||
D::Descriptor(
|
||||
Descriptor::new("PaCm")
|
||||
.with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) })
|
||||
.with("SbpL", D::List(subs)),
|
||||
)
|
||||
};
|
||||
let components = if joined {
|
||||
vec![component("Add ", vec![sub(outer), sub(inner)])]
|
||||
} else {
|
||||
vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])]
|
||||
};
|
||||
Descriptor::new(if joined { "null" } else { "setd" })
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }]))
|
||||
.with("T ", D::List(components))
|
||||
}
|
||||
|
||||
/// Extract the single executable Work Path assignment from a measured descriptor.
|
||||
fn params(d: &Descriptor) -> Value {
|
||||
let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") };
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].0, "path.set");
|
||||
steps[0].1.clone()
|
||||
}
|
||||
|
||||
/// Sample the filled contour, its hole and the untouched exterior independently.
|
||||
fn check_area(path: &Path) {
|
||||
let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300));
|
||||
for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] {
|
||||
assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_curves_and_component_operations_round_trip_and_cut_holes() {
|
||||
for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] {
|
||||
let d = fixture(operation, false, false);
|
||||
let p = params(&d);
|
||||
let path = crate::vector_cmds::parse_path(&p["path"]).unwrap();
|
||||
assert_eq!(path.fill_rule, FillRule::EvenOdd);
|
||||
assert_eq!(path.subpaths[1].op, expected);
|
||||
let knots = &path.subpaths[0].knots;
|
||||
assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0));
|
||||
assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0));
|
||||
assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0));
|
||||
assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0));
|
||||
assert_eq!(to(&p), Some(d));
|
||||
check_area(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_component_has_even_odd_holes_with_either_inner_winding() {
|
||||
for reversed in [false, true] {
|
||||
let d = fixture("Add ", true, reversed);
|
||||
let p = params(&d);
|
||||
let path = crate::vector_cmds::parse_path(&p["path"]).unwrap();
|
||||
assert_eq!(path.components(), vec![0..2]);
|
||||
assert_eq!(path.subpaths[1].op, PathOp::Join);
|
||||
check_area(&path);
|
||||
assert_eq!(to(&p), Some(d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_atn_and_native_insert_path_replay_keep_geometry() {
|
||||
let original = fixture("Sbtr", false, false);
|
||||
let file = AtnFile {
|
||||
actions: vec![AtnAction {
|
||||
items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let imported = super::super::import(&atn::write(&file).unwrap()).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone()));
|
||||
let expected = path_from(&original).unwrap();
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", json!({"width":400,"height":300})).unwrap();
|
||||
s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap();
|
||||
s.actions.list.push(Action::new("Captured", Vec::new()));
|
||||
let revision = s.active().unwrap().revision;
|
||||
s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let imported = super::super::import(&bytes).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
s.actions.list = imported.actions;
|
||||
s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected));
|
||||
check_area(s.active().unwrap().doc.work_path.as_ref().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_wire_identity_is_retained_only_for_unchanged_geometry() {
|
||||
let mut d = fixture("xor", false, false);
|
||||
d.name = UnicodeString::new_nul("Set");
|
||||
d.items.reverse();
|
||||
let mut p = params(&d);
|
||||
assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes());
|
||||
p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0);
|
||||
assert!(to(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_unrepresented_settings_are_not_silently_lost() {
|
||||
let base = fixture("Sbtr", false, false);
|
||||
let mut empty_components = base.clone();
|
||||
let (_, components) = empty_components.items.iter_mut().find(|(key, _)| key.is("T ")).unwrap();
|
||||
*components = D::List(Vec::new());
|
||||
for d in [base.clone().with("extra", D::Boolean(true)), empty_components, fixture("Intr", false, false)] {
|
||||
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
|
||||
}
|
||||
let p = params(&base);
|
||||
let mut empty = p.clone();
|
||||
empty["path"]["subpaths"] = json!([]);
|
||||
empty.as_object_mut().unwrap().remove(SOURCE);
|
||||
assert!(to(&empty).is_none());
|
||||
for key in ["inverted", "fillRule"] {
|
||||
let mut changed = p.clone();
|
||||
changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") };
|
||||
changed.as_object_mut().unwrap().remove(SOURCE);
|
||||
assert!(to(&changed).is_none());
|
||||
}
|
||||
let mut changed = p.clone();
|
||||
changed["path"]["unrepresented"] = json!(1);
|
||||
assert!(to(&changed).is_none());
|
||||
let mut changed = p;
|
||||
changed[SOURCE] = json!("not base64");
|
||||
assert!(to(&changed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_coordinates_and_oversized_paths_stay_unsupported() {
|
||||
fn list(value: &mut D) -> &mut Vec<D> {
|
||||
match value {
|
||||
D::List(v) => v,
|
||||
_ => panic!("fixture list"),
|
||||
}
|
||||
}
|
||||
fn object(value: &mut D) -> &mut Descriptor {
|
||||
match value {
|
||||
D::Descriptor(v) => v,
|
||||
_ => panic!("fixture object"),
|
||||
}
|
||||
}
|
||||
for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] {
|
||||
let mut d = fixture("Sbtr", false, false);
|
||||
let components = list(&mut d.items[1].1);
|
||||
let subs = list(&mut object(&mut components[0]).items[1].1);
|
||||
let points = list(&mut object(&mut subs[0]).items[1].1);
|
||||
let anchor = object(&mut points[0]);
|
||||
let coordinates = object(&mut anchor.items[0].1);
|
||||
coordinates.items[0].1 = bad;
|
||||
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
|
||||
}
|
||||
let mut p = params(&fixture("Sbtr", false, false));
|
||||
p.as_object_mut().unwrap().remove(SOURCE);
|
||||
let knot = p["path"]["subpaths"][0]["knots"][0].clone();
|
||||
p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]);
|
||||
assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable");
|
||||
p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]);
|
||||
assert!(to(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() {
|
||||
// Each name meets the local limit, but their aggregate exceeds the carrier.
|
||||
// Keep this typed input independent of the serializer under test.
|
||||
let mut point =
|
||||
Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 });
|
||||
point.name = UnicodeString(vec![b'x' as u16; 4096]);
|
||||
let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point));
|
||||
knot.name = UnicodeString(vec![b'x' as u16; 4096]);
|
||||
let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300]));
|
||||
let component = Descriptor::new("PaCm")
|
||||
.with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") })
|
||||
.with("SbpL", D::List(vec![D::Descriptor(sub)]));
|
||||
let d = Descriptor::new("setd")
|
||||
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }]))
|
||||
.with("T ", D::List(vec![D::Descriptor(component)]));
|
||||
assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor");
|
||||
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! #10710: Actions steps recorded with the Gradient tool and Create Droplet, as the issue's table
|
||||
//! gives them (measured in `docs/atn.md` › "Measured through dialogs and recordings"; once mapped,
|
||||
//! the events are listed with the supported events instead).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn action(items: Vec<AtnItem>) -> Vec<u8> {
|
||||
atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul("Recorded"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn step(event: &[u8; 4], label: &str, d: Descriptor) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::Code(*event), dictionary: label.as_bytes().to_vec(), descriptor: Some(d), ..Default::default() }
|
||||
}
|
||||
|
||||
fn saved(s: &mut Session) -> Vec<AtnItem> {
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items
|
||||
}
|
||||
|
||||
/// The gradient object the Gradient tool records: dither, classic interpolation, angle, type,
|
||||
/// scale and offset in percent, then the two-stop gradient.
|
||||
fn tool_gradient(angle: f64, scale: f64, offset: (f64, f64)) -> Descriptor {
|
||||
let stop = |hex: &str, at: i32| {
|
||||
D::Descriptor(
|
||||
desc("Clrt")
|
||||
.with("Clr ", D::Descriptor(color_desc(hex).unwrap()))
|
||||
.with("Type", enm("Clry", "UsrS"))
|
||||
.with("Lctn", D::Integer(at))
|
||||
.with("Mdpn", D::Integer(50)),
|
||||
)
|
||||
};
|
||||
let trn = |at: i32| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
|
||||
let grad = desc("Grdn")
|
||||
.with("Nm ", text("Custom"))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(vec![stop("#102030", 0), stop("#f0e0d0", 4096)]))
|
||||
.with("Trns", D::List(vec![trn(0), trn(4096)]));
|
||||
desc("gradientLayer")
|
||||
.with("Dthr", D::Boolean(true))
|
||||
.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls"))
|
||||
.with("Angl", ang(angle))
|
||||
.with("Type", enm("GrdT", "Lnr "))
|
||||
.with("Scl ", pct(scale))
|
||||
.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(offset.0)).with("Vrtc", pct(offset.1))))
|
||||
.with("Grad", D::Descriptor(grad))
|
||||
}
|
||||
|
||||
/// The Gradient tool (default mode) records a Make of a gradient fill layer at the first click and
|
||||
/// a Set with the finished drag (`Algn` false). Both load as their commands, play, and save back
|
||||
/// as recorded.
|
||||
#[test]
|
||||
fn gradient_tool_steps_load_play_and_save_back() {
|
||||
let make = desc("null")
|
||||
.with("null", class_ref("contentLayer"))
|
||||
.with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(tool_gradient(90.0, 100.0, (0.0, 0.0))))));
|
||||
let set = desc("null")
|
||||
.with("null", target_ref("contentLayer"))
|
||||
.with("T ", D::Descriptor(tool_gradient(-35.0, 140.0, (12.5, -8.0)).with("Algn", D::Boolean(false))));
|
||||
let items = vec![step(b"Mk ", "Make", make.clone()), step(b"setd", "Set", set.clone())];
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(items))})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["layer.newFillLayer.gradient", "gradient.fill.set"]);
|
||||
let set_params = &s.actions.list[0].steps[1].1;
|
||||
assert_eq!(
|
||||
(set_params["angle"].as_f64(), set_params["scale"].as_f64(), set_params["align"].as_bool()),
|
||||
(Some(-35.0), Some(140.0), Some(false)),
|
||||
"{set_params}"
|
||||
);
|
||||
|
||||
s.execute("file.new", json!({"width": 20, "height": 12})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(played["ran"], 2, "{played}");
|
||||
|
||||
let back = saved(&mut s);
|
||||
assert_eq!(back.len(), 2);
|
||||
assert_eq!((back[0].event.id(), back[0].descriptor.as_ref()), ("Mk ".to_string(), Some(&make)));
|
||||
assert_eq!((back[1].event.id(), back[1].descriptor.as_ref()), ("setd".to_string(), Some(&set)));
|
||||
}
|
||||
|
||||
/// A Create Droplet step as the reference application records it with a Folder destination and
|
||||
/// Override Action "Save As" on.
|
||||
fn droplet(file: &str, output: &str, extra: &[(&str, D)]) -> Descriptor {
|
||||
let reference = D::Reference(vec![
|
||||
ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") },
|
||||
ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Tools") },
|
||||
]);
|
||||
let mut d = desc("null").with("In ", path_value(file)).with("Fldr", D::Boolean(false)).with("Usng", reference);
|
||||
for (k, v) in extra.iter().take_while(|(k, _)| *k == "OvrO") {
|
||||
d = d.with(k, v.clone());
|
||||
}
|
||||
d = d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true));
|
||||
for (k, v) in extra.iter().filter(|(k, _)| *k != "OvrO") {
|
||||
d = d.with(k, v.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Create Droplet loads as `file.automate.createDroplet`, plays (writes the droplet with the
|
||||
/// action's steps) and saves back as recorded.
|
||||
#[test]
|
||||
fn create_droplet_step_loads_plays_and_saves_back() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-10710-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Sharpen.pcdroplet").to_string_lossy().into_owned();
|
||||
let out = dir.join("out").to_string_lossy().into_owned();
|
||||
let recorded = droplet(&file, &out, &[("OvrO", D::Boolean(true))]);
|
||||
let mut s = Session::new();
|
||||
// The action the droplet runs.
|
||||
s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))]));
|
||||
s.actions.list[0].set = Some("Tools".into());
|
||||
let loaded = s
|
||||
.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(vec![step(b"CrtD", "Create Droplet", recorded.clone())]))}))
|
||||
.unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
let a = s.actions.list.iter().find(|a| a.name == "A").unwrap();
|
||||
assert_eq!(
|
||||
a.steps,
|
||||
[("file.automate.createDroplet".to_string(), json!({"path": file, "action": "Sharpen", "set": "Tools", "output": out, "overrideOpen": true}))]
|
||||
);
|
||||
let r = s.execute("file.automate.createDroplet", a.steps[0].1.clone()).unwrap();
|
||||
assert_eq!(r["path"], json!(file));
|
||||
let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(written["action"]["steps"], json!([["filter.sharpen.sharpen", {}]]));
|
||||
assert_eq!((written["options"]["output"].as_str(), written["options"]["overrideOpen"].as_bool()), (Some(out.as_str()), Some(true)));
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
let back = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let item = back.actions.iter().find(|a| a.name.to_string_lossy() == "A").unwrap().items[0].clone();
|
||||
assert_eq!((item.event.id(), item.descriptor), ("CrtD".to_string(), Some(recorded)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// What the droplet cannot do keeps the Photoshop step: Include All Subfolders, Save and Close,
|
||||
/// no destination, Save As steps left to save where recorded, suppressed dialogs, custom naming
|
||||
/// and an error log.
|
||||
#[test]
|
||||
fn create_droplet_steps_the_droplet_cannot_play_are_kept() {
|
||||
let kept = |d: Descriptor| matches!(steps_droplet::droplet_from(&d), Mapped::Unsupported { harmless: false });
|
||||
let base = || droplet("/d/S.pcdroplet", "/d/out", &[]);
|
||||
let replaced = |key: &str, v: D| {
|
||||
let mut d = base();
|
||||
d.items.retain(|(k, _)| !k.is(key));
|
||||
d.with(key, v)
|
||||
};
|
||||
assert!(!kept(base()), "the plain form maps");
|
||||
assert!(kept(replaced("Fldr", D::Boolean(true))));
|
||||
assert!(kept(replaced("Ovrd", D::Boolean(false))));
|
||||
let mut no_destination = base();
|
||||
no_destination.items.retain(|(k, _)| !k.is("T "));
|
||||
assert!(kept(no_destination.clone()));
|
||||
assert!(kept(no_destination.with("SvAn", D::Boolean(true))));
|
||||
assert!(kept(base().with("suppressOpenOptions", D::Boolean(true))));
|
||||
assert!(kept(base().with("fileNamingRules", D::Descriptor(desc("fileNamingRules")))));
|
||||
assert!(kept(base().with("Log ", path_value("/d/logs"))));
|
||||
}
|
||||
|
||||
/// Settings the event has no key for refuse the step on save rather than replay differently.
|
||||
#[test]
|
||||
fn create_droplet_steps_that_cannot_be_written_are_refused() {
|
||||
let p = json!({"path": "/d/S.pcdroplet", "action": "Sharpen", "output": "/d/out"});
|
||||
assert!(export_item("file.automate.createDroplet", &p, "S").is_some());
|
||||
for extra in [json!({"format": "png"}), json!({"quality": 8}), json!({"steps": [["filter.sharpen.sharpen", {}]]}), json!({"name": "Other"})] {
|
||||
let mut q = p.clone();
|
||||
q.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(export_item("file.automate.createDroplet", &q, "S").is_none(), "{q}");
|
||||
}
|
||||
let mut no_output = p.clone();
|
||||
no_output.as_object_mut().unwrap().remove("output");
|
||||
assert!(export_item("file.automate.createDroplet", &no_output, "S").is_none());
|
||||
}
|
||||
|
||||
/// Loading a set whose action clashes with one already in the panel renames the loaded copy; its
|
||||
/// Create Droplet steps follow the rename, as Batch steps do, whether the reference names the
|
||||
/// action alone or the action in the file's own set (CodeRabbit review on steps_droplet.rs:59).
|
||||
#[test]
|
||||
fn create_droplet_steps_follow_a_renamed_loaded_action() {
|
||||
let with_ref = |items: Vec<ReferenceItem>| {
|
||||
let mut d = droplet("/d/S.pcdroplet", "/d/out", &[]);
|
||||
d.items.retain(|(k, _)| !k.is("Usng"));
|
||||
d.with("Usng", D::Reference(items))
|
||||
};
|
||||
let actn = || ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") };
|
||||
let own_set = ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Recorded") };
|
||||
let file = atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul("Recorded"),
|
||||
actions: vec![
|
||||
AtnAction { name: UnicodeString::new_nul("Sharpen"), ..Default::default() },
|
||||
AtnAction {
|
||||
name: UnicodeString::new_nul("A"),
|
||||
items: vec![step(b"CrtD", "Create Droplet", with_ref(vec![actn()])), step(b"CrtD", "Create Droplet", with_ref(vec![actn(), own_set]))],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![]));
|
||||
s.actions.list[0].set = Some("Tools".into());
|
||||
let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file), "set": "Loaded"})).unwrap();
|
||||
assert_eq!(r["actions"], json!(["Sharpen 2", "A"]), "{r}");
|
||||
let a = s.actions.list.iter().find(|a| a.name == "A").unwrap();
|
||||
assert_eq!(a.steps[0].1["action"], "Sharpen 2", "{:?}", a.steps[0]);
|
||||
assert_eq!((&a.steps[1].1["action"], &a.steps[1].1["set"]), (&json!("Sharpen 2"), &json!("Loaded")), "{:?}", a.steps[1]);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! File › Automate › Create Droplet (`CrtD`, #10710): the droplet file (`In `), the action
|
||||
//! (`Usng`) and the Batch keys, as #10710 lists them from the reference application. Only the form
|
||||
//! `file.automate.createDroplet` plays the same way maps; any other stays a Photoshop step.
|
||||
|
||||
use super::descriptors::{class, desc, jstr, path_text, path_value};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json};
|
||||
|
||||
/// The keys a recorded Create Droplet step can carry (the Batch keys, with `In ` for the source).
|
||||
const KEYS: &[&str] = &["In ", "Fldr", "Usng", "OvrO", "suppressWarnings", "suppressOpenOptions", "T ", "SvAn", "Ovrd", "fileNamingRules", "Log ", "Nm "];
|
||||
|
||||
fn flag(d: &Descriptor, key: &str) -> Option<bool> {
|
||||
match d.get(key) {
|
||||
None => Some(false),
|
||||
Some(D::Boolean(b)) => Some(*b),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A droplet that saves each file into a folder and overrides the action's Save As steps maps:
|
||||
/// that is what `file.automate.createDroplet` writes (its output folder replaces recorded saves).
|
||||
/// Include All Subfolders (the droplet lists a folder's own images only), Save and Close or no
|
||||
/// destination, Save As steps left to save where they were recorded, suppressed warnings or open
|
||||
/// options, custom file naming and an error log have no field in the droplet, so they keep the
|
||||
/// Photoshop step.
|
||||
pub(super) fn droplet_from(d: &Descriptor) -> Mapped {
|
||||
read(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.createDroplet", Value::Object(p)))
|
||||
}
|
||||
|
||||
fn read(d: &Descriptor) -> Option<Map<String, Value>> {
|
||||
if !d.items.iter().all(|(k, _)| KEYS.iter().any(|s| k.is(s))) {
|
||||
return None;
|
||||
}
|
||||
let off = |k: &str| flag(d, k) == Some(false);
|
||||
if !off("Fldr") || !off("SvAn") || !off("suppressWarnings") || !off("suppressOpenOptions") || flag(d, "Ovrd") != Some(true) {
|
||||
return None;
|
||||
}
|
||||
if ["fileNamingRules", "Log ", "Nm "].iter().any(|k| d.get(k).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let mut p = Map::new();
|
||||
p.insert("path".into(), json!(path_text(d.get("In ")?)?));
|
||||
let D::Reference(items) = d.get("Usng")? else { return None };
|
||||
match items.as_slice() {
|
||||
[ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }]
|
||||
if action.class_id.is("Actn") && set.class_id.is("ASet") =>
|
||||
{
|
||||
p.insert("action".into(), json!(name.to_string_lossy()));
|
||||
p.insert("set".into(), json!(set_name.to_string_lossy()));
|
||||
}
|
||||
[ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => {
|
||||
p.insert("action".into(), json!(name.to_string_lossy()));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
p.insert("output".into(), json!(path_text(d.get("T ")?)?));
|
||||
if flag(d, "OvrO")? {
|
||||
p.insert("overrideOpen".into(), json!(true));
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Written as `read` reads it: the droplet file, Include All Subfolders off, the action by name
|
||||
/// (in its set when the step names one), Override Action "Open" only when on, the destination
|
||||
/// folder and Override Action "Save As" on. Any other setting refuses the step.
|
||||
pub(super) fn droplet_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let allowed = ["path", "action", "set", "output", "overrideOpen"];
|
||||
if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !allowed.contains(&k.as_str())) {
|
||||
return None;
|
||||
}
|
||||
let path = jstr(p, "path").filter(|s| !s.is_empty())?;
|
||||
let action = jstr(p, "action").filter(|s| !s.is_empty())?;
|
||||
let output = jstr(p, "output").filter(|s| !s.is_empty())?;
|
||||
let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }];
|
||||
if let Some(set) = p.get("set").filter(|v| !v.is_null()) {
|
||||
reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) });
|
||||
}
|
||||
let mut d = desc("null").with("In ", path_value(path)).with("Fldr", D::Boolean(false)).with("Usng", D::Reference(reference));
|
||||
match p.get("overrideOpen") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => {}
|
||||
Some(Value::Bool(true)) => d = d.with("OvrO", D::Boolean(true)),
|
||||
Some(_) => return None,
|
||||
}
|
||||
Some(d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true)))
|
||||
}
|
||||
@@ -600,11 +600,12 @@ pub(crate) const RECORDED_EVENT: &str = "_atnEvent";
|
||||
/// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`).
|
||||
/// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft.
|
||||
pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) {
|
||||
if !is_effect(&item.event) {
|
||||
return;
|
||||
}
|
||||
let [(id, Value::Object(p))] = steps else { return };
|
||||
if effect_command(&item.event) != Some(id.as_str()) {
|
||||
let menu = id == super::MENU_ITEM
|
||||
&& item.descriptor.is_none()
|
||||
&& super::menu_item::from_event(&item.event)
|
||||
.is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind"));
|
||||
if !menu && effect_command(&item.event) != Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
let kind = match item.event {
|
||||
@@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) {
|
||||
p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary}));
|
||||
}
|
||||
|
||||
/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves
|
||||
/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect.
|
||||
/// Return remembered event/name bytes only while they still match the current command and kind.
|
||||
/// Otherwise the exporter uses that command's canonical event and name.
|
||||
pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec<u8>)> {
|
||||
let r = params.get(RECORDED_EVENT)?;
|
||||
let event = jstr(r, "event")?;
|
||||
@@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec<
|
||||
Some(bytes) => bytes,
|
||||
None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(),
|
||||
};
|
||||
(effect_command(&event) == Some(id)).then_some((event, name))
|
||||
let matches = if id == super::MENU_ITEM {
|
||||
super::menu_item::from_event(&event)
|
||||
.is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind"))
|
||||
} else {
|
||||
effect_command(&event) == Some(id)
|
||||
};
|
||||
matches.then_some((event, name))
|
||||
}
|
||||
|
||||
/// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's
|
||||
|
||||
@@ -1164,6 +1164,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
|
||||
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
match (ref_class(r).as_deref(), target(r)) {
|
||||
(Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d),
|
||||
(Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d),
|
||||
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
|
||||
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
|
||||
@@ -1270,6 +1271,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]);
|
||||
let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t);
|
||||
Some(match id {
|
||||
"path.set" => return super::steps_path::to(p),
|
||||
"select.all" => sel(enm("Ordn", "Al ")),
|
||||
"select.deselect" => sel(enm("Ordn", "None")),
|
||||
"select.reselect" => sel(enm("Ordn", "Prvs")),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Measured Insert Path: per-component operations, with even-odd subpaths in each component.
|
||||
use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
|
||||
use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath};
|
||||
use photocraft_geom::Point;
|
||||
|
||||
pub(super) const SOURCE: &str = "_atnInsertPathDescriptor";
|
||||
// The native path validator admits at most 4096 knots/subpaths. Larger wire
|
||||
// records remain complete kept steps rather than secondary geometry allocations.
|
||||
const MAX_PARTS: usize = 4096;
|
||||
const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Bound the retained wire form before serializing it into a second allocation.
|
||||
/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed
|
||||
/// key/type/scalar or list header. Nested descriptors charge their own fields.
|
||||
fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool {
|
||||
let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32));
|
||||
let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false };
|
||||
*budget = remaining;
|
||||
d.name.0.len() <= 4096
|
||||
&& d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key)))
|
||||
&& required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
|
||||
&& optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1)
|
||||
}
|
||||
|
||||
/// Require the recorded object class before interpreting its fields as geometry.
|
||||
fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> {
|
||||
match value {
|
||||
D::Descriptor(d) if d.class_id.is(class_id) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a pixel point within the native coordinate range and charge its fields to the wire budget.
|
||||
fn point(value: &D, budget: &mut usize) -> Option<Point> {
|
||||
let d = object(value, "Pnt ")?;
|
||||
if !keys(d, &["Hrzn", "Vrtc"], &[], budget) {
|
||||
return None;
|
||||
}
|
||||
let coordinate = |key| match d.get(key)? {
|
||||
D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value),
|
||||
_ => None,
|
||||
};
|
||||
Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?))
|
||||
}
|
||||
|
||||
/// A corner stores only its anchor. A cubic knot must supply both handles and its smooth flag.
|
||||
fn knot(value: &D, budget: &mut usize) -> Option<Knot> {
|
||||
let d = object(value, "Pthp")?;
|
||||
if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) {
|
||||
return None;
|
||||
}
|
||||
let anchor = point(d.get("Anch")?, budget)?;
|
||||
match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) {
|
||||
(None, None, None) => Some(Knot::corner(anchor.x, anchor.y)),
|
||||
(Some(out), Some(input), Some(D::Boolean(smooth))) => {
|
||||
Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a Work Path property assignment, preserving the grouping and operation of each component.
|
||||
fn path_from(d: &Descriptor) -> Option<Path> {
|
||||
let mut budget = MAX_SOURCE_BYTES;
|
||||
if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) {
|
||||
return None;
|
||||
}
|
||||
let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None };
|
||||
if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") {
|
||||
return None;
|
||||
}
|
||||
budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?;
|
||||
let components = get_list(d, "T ")?;
|
||||
if components.is_empty() || components.len() > MAX_PARTS {
|
||||
return None;
|
||||
}
|
||||
let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() };
|
||||
let mut count = 0usize;
|
||||
for value in components {
|
||||
let component = object(value, "PaCm")?;
|
||||
if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) {
|
||||
return None;
|
||||
}
|
||||
let op = match component.get("shapeOperation")? {
|
||||
D::Enumerated { type_id, value } if type_id.is("shapeOperation") => {
|
||||
if value.is("Add ") {
|
||||
PathOp::Combine
|
||||
} else if value.is("Sbtr") {
|
||||
PathOp::Subtract
|
||||
} else if value.is("xor") {
|
||||
PathOp::Exclude
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if path.subpaths.is_empty() && op != PathOp::Combine {
|
||||
return None;
|
||||
}
|
||||
let subpaths = get_list(component, "SbpL")?;
|
||||
if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS {
|
||||
return None;
|
||||
}
|
||||
for (i, value) in subpaths.iter().enumerate() {
|
||||
let sub = object(value, "Sbpl")?;
|
||||
if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) {
|
||||
return None;
|
||||
}
|
||||
let D::Boolean(closed) = sub.get("Clsp")? else { return None };
|
||||
let points = get_list(sub, "Pts ")?;
|
||||
count = count.checked_add(points.len())?;
|
||||
if count > MAX_PARTS {
|
||||
return None;
|
||||
}
|
||||
let knots = points.iter().map(|value| knot(value, &mut budget)).collect::<Option<Vec<_>>>()?;
|
||||
path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } });
|
||||
}
|
||||
}
|
||||
photocraft_vector::flatten::validate_path(&path).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Decode only the measured Work Path form; unsupported data stays a complete kept step.
|
||||
pub(super) fn from(d: &Descriptor) -> Mapped {
|
||||
let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } };
|
||||
let raw = d.to_bytes();
|
||||
if raw.len() > MAX_SOURCE_BYTES {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)}))
|
||||
}
|
||||
|
||||
/// Write a point using pixel units for both coordinates.
|
||||
fn point_to(p: Point) -> D {
|
||||
D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y)))
|
||||
}
|
||||
|
||||
/// Emit the compact corner form only when the handles coincide and the knot has no smooth flag.
|
||||
fn knot_to(k: &Knot) -> D {
|
||||
let mut d = desc("Pthp").with("Anch", point_to(k.anchor));
|
||||
if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth {
|
||||
d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth));
|
||||
}
|
||||
D::Descriptor(d)
|
||||
}
|
||||
|
||||
/// Export representable native geometry or an unchanged validated source descriptor.
|
||||
/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd.
|
||||
pub(super) fn to(p: &Value) -> Option<Descriptor> {
|
||||
if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) {
|
||||
return None;
|
||||
}
|
||||
let value = p.get("path")?;
|
||||
let subpaths = value.get("subpaths")?.as_array()?;
|
||||
if subpaths.is_empty()
|
||||
|| subpaths.len() > MAX_PARTS
|
||||
|| subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = crate::vector_cmds::parse_path(value).ok()?;
|
||||
photocraft_vector::flatten::validate_path(&path).ok()?;
|
||||
let mut canonical = crate::vector_cmds::path_json(&path);
|
||||
let mut supplied = value.clone();
|
||||
super::convert::normalise(&mut canonical);
|
||||
super::convert::normalise(&mut supplied);
|
||||
if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied {
|
||||
return None;
|
||||
}
|
||||
if let Some(source) = p.get(SOURCE) {
|
||||
let text = source.as_str()?;
|
||||
if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 {
|
||||
return None;
|
||||
}
|
||||
let raw = photocraft_paint::tile::b64_decode(text)?;
|
||||
if raw.len() > MAX_SOURCE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let original = Descriptor::from_bytes(&raw).ok()?;
|
||||
return (path_from(&original)? == path).then_some(original);
|
||||
}
|
||||
let mut components = Vec::new();
|
||||
for range in path.components() {
|
||||
let first = path.subpaths.get(range.start)?;
|
||||
let op = match first.op {
|
||||
PathOp::Combine => "Add ",
|
||||
PathOp::Subtract => "Sbtr",
|
||||
PathOp::Exclude => "xor",
|
||||
_ => return None,
|
||||
};
|
||||
if components.is_empty() && first.op != PathOp::Combine {
|
||||
return None;
|
||||
}
|
||||
let subs = path
|
||||
.subpaths
|
||||
.get(range)?
|
||||
.iter()
|
||||
.map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect()))))
|
||||
.collect();
|
||||
components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs))));
|
||||
}
|
||||
Some(
|
||||
desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)),
|
||||
)
|
||||
}
|
||||
@@ -741,6 +741,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
|
||||
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
|
||||
("filter.blur.blur", json!({})),
|
||||
("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})),
|
||||
("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})),
|
||||
("filter.distort.pinch", json!({"amount": -30})),
|
||||
("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})),
|
||||
@@ -834,6 +835,13 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
|
||||
("type.convertToShape", json!({})),
|
||||
("type.createWorkPath", json!({})),
|
||||
(
|
||||
"path.set",
|
||||
json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[
|
||||
{"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false},
|
||||
{"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false}
|
||||
]}]}}),
|
||||
),
|
||||
("filter.blur.shapeBlur", json!({})),
|
||||
("filter.blur.shapeBlur", json!({"radius": 10})),
|
||||
("layer.new.group", json!({"name": "G"})),
|
||||
@@ -1114,6 +1122,10 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
// Executable values match; independent public tests verify the source carrier.
|
||||
back.as_object_mut().unwrap().remove("__atnClassicGradient");
|
||||
}
|
||||
if *id == "path.set" {
|
||||
// The imported step retains the original descriptor alongside its playable path.
|
||||
back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap();
|
||||
}
|
||||
if *id == "edit.perspectiveWarp" {
|
||||
back.as_object_mut().unwrap().remove("__atnPerspectiveWarp");
|
||||
}
|
||||
@@ -1407,6 +1419,7 @@ 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),
|
||||
item("GsnB", "Gaussian Blur", Some(desc("null"))),
|
||||
// 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(
|
||||
@@ -1428,8 +1441,11 @@ 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", PHOTOSHOP_STEP]);
|
||||
assert_eq!(imp.unsupported.len(), 5);
|
||||
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
|
||||
assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur");
|
||||
assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code");
|
||||
assert_eq!(imp.actions[0].steps[5].1, json!({}));
|
||||
assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::<Vec<_>>(), [0, 1, 2, 3, 6]);
|
||||
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
|
||||
}
|
||||
|
||||
|
||||
@@ -832,6 +832,21 @@ fn mixer_load_pixel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
|
||||
fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> {
|
||||
let cmd = "paint.colorReplacement";
|
||||
// The modes and depths the reference application refuses, with its words (#11923). The live
|
||||
// stroke runs this setup too, so it previews nothing in them. 32-bit Grayscale, CMYK and Lab,
|
||||
// Multichannel and Duotone weren't measured and keep painting.
|
||||
if let Some(st) = s.active() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
let why = match (st.doc.mode, st.doc.depth) {
|
||||
(ColorMode::Indexed, _) => Some("it does not work with Indexed Color images (convert image to RGB Color mode to edit)."),
|
||||
(ColorMode::Bitmap, _) | (ColorMode::Grayscale, SampleType::U8 | SampleType::U16) => Some("it only works in full color modes."),
|
||||
(ColorMode::Rgb, SampleType::F32) => Some("it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit)."),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(why) = why {
|
||||
return Err(EngineError::Other(format!("Could not use the Color Replacement tool because {why}")));
|
||||
}
|
||||
}
|
||||
if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels"))
|
||||
&& crate::color_channels::explicit(p, s.active().map_or(0, |st| crate::channel_cmds::color_count(&st.doc)))?.is_none()
|
||||
{
|
||||
@@ -855,9 +870,14 @@ fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSet
|
||||
Ok((Stroke { brush, points: pts }, rs, id))
|
||||
}
|
||||
|
||||
/// The History state (and recorded step) name of a Color Replacement edit.
|
||||
pub const COLOR_REPLACEMENT_STEP: &str = "Color Replacement Tool";
|
||||
|
||||
fn color_replacement(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (stroke, rs, id) = color_replacement_setup(s, p)?;
|
||||
let dmg = s.edit("Color Replacement", |doc, _| {
|
||||
// The reference application names every click, stroke and Shift+click segment "Color
|
||||
// Replacement Tool" (#11933); the command's label is the same, so recorded actions match.
|
||||
let dmg = s.edit(COLOR_REPLACEMENT_STEP, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let lock = doc.effective_locks(id).transparency;
|
||||
let surf = paint_surface(doc, id, &json!({}))?;
|
||||
@@ -1105,6 +1125,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
b = merge_brush(&b, &patch, cmd)?;
|
||||
validate_brush(&b, cmd)?;
|
||||
// The painting tools' Opacity takes 1-100 %, as in Photoshop: a 0 % brush paints nothing (#11812).
|
||||
b.opacity = b.opacity.max(0.01);
|
||||
// Loading a stored tip can fail. Finish preparing the brush before changing its identity or
|
||||
// foreground colour, so a failed pick leaves the entire current brush state intact.
|
||||
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
|
||||
@@ -1170,8 +1192,8 @@ pub fn remembered_settings(b: &BrushSettings) -> Option<Value> {
|
||||
|
||||
/// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is
|
||||
/// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The
|
||||
/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness,
|
||||
/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else
|
||||
/// options-bar values are clamped to the ranges their controls allow (size 1..max px; opacity
|
||||
/// 1..100 %; hardness, flow and smoothing 0..100 %), and stored tips load when the store is attached (else
|
||||
/// they paint with their previews until `Session::attach_preset_store`).
|
||||
pub fn restored_settings(s: &Session, v: &Value) -> Option<BrushSettings> {
|
||||
if !v.is_object() {
|
||||
@@ -1183,6 +1205,9 @@ pub fn restored_settings(s: &Session, v: &Value) -> Option<BrushSettings> {
|
||||
for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] {
|
||||
*f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) };
|
||||
}
|
||||
// Opacity takes 1-100 %, as `tools.setBrush` floors it: a brush remembered at 0 % (saved
|
||||
// before that floor) would show 1 % but paint nothing (#11812).
|
||||
b.opacity = b.opacity.max(0.01);
|
||||
let _ = s.load_brush_tips(&mut b);
|
||||
Some(b)
|
||||
}
|
||||
@@ -1335,7 +1360,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!(
|
||||
"paint.colorReplacement",
|
||||
"Color Replacement",
|
||||
"Color Replacement Tool",
|
||||
r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##,
|
||||
crate::commands::has_paintable,
|
||||
color_replacement,
|
||||
@@ -1365,7 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"tools.setBrush",
|
||||
"Set Brush",
|
||||
r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##,
|
||||
r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias; opacity floors at 0.01)}"##,
|
||||
always,
|
||||
set_brush,
|
||||
true
|
||||
@@ -1376,5 +1401,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "color_replacement_modes_tests.rs"]
|
||||
mod color_replacement_modes_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixer_live_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "color_replacement_history_name_tests.rs"]
|
||||
mod color_replacement_history_name_tests;
|
||||
@@ -151,6 +151,7 @@ impl LiveMixer {
|
||||
|
||||
/// Validate the params and capture the document and paint load, without rendering a dab.
|
||||
fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec<StrokePoint>)> {
|
||||
mode_refusal(s)?;
|
||||
let captured = crate::channel_cmds::inject_target(s, CMD, p.clone());
|
||||
let p = &captured;
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
@@ -330,9 +331,26 @@ impl LiveMixer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's alert for a Mixer Brush press in an Indexed Color document (#11958).
|
||||
pub const INDEXED_REFUSAL: &str =
|
||||
"Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit).";
|
||||
/// The reference's alert for a Mixer Brush press in a Bitmap document (#11958).
|
||||
pub const BITMAP_REFUSAL: &str = "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit).";
|
||||
|
||||
/// The Mixer Brush refuses Indexed Color and Bitmap documents before anything is painted, with the
|
||||
/// reference's alert text; other modes (Duotone and Multichannel were not measured) paint as before.
|
||||
fn mode_refusal(s: &Session) -> Result<()> {
|
||||
match s.active().map(|st| st.doc.mode) {
|
||||
Some(photocraft_color::ColorMode::Indexed) => Err(EngineError::Other(INDEXED_REFUSAL.into())),
|
||||
Some(photocraft_color::ColorMode::Bitmap) => Err(EngineError::Other(BITMAP_REFUSAL.into())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a matching prepared result or replay without the preview tile cap, then commit one history step.
|
||||
/// The completed stroke also applies the automatic clean/load settings to the stored paint load.
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mode_refusal(s)?;
|
||||
let points = parse_points(p, CMD)?;
|
||||
let cached =
|
||||
s.live_commit.take().filter(|live| live.matches(s, CMD, p, &points) && live.mixer.as_ref().is_some_and(|(before, _)| *before == s.tools.mixer));
|
||||
|
||||
@@ -1380,6 +1380,22 @@ fn restored_brush_settings_are_clamped_or_rejected() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #11812: a brush remembered at 0 % opacity (saved before the 1 % floor) reads back at 1 %, the
|
||||
/// value its options-bar field shows, so it paints instead of laying down nothing.
|
||||
#[test]
|
||||
fn restored_brush_opacity_floors_at_one_percent() {
|
||||
let s = Session::new();
|
||||
for opacity in [0.0, -1.0, 0.004] {
|
||||
let b = restored_settings(&s, &json!({"opacity": opacity})).unwrap();
|
||||
assert_eq!(b.opacity, 0.01, "{opacity}");
|
||||
}
|
||||
assert_eq!(restored_settings(&s, &json!({"opacity": 0.37})).unwrap().opacity, 0.37, "values above the floor are kept");
|
||||
let mut t = Session::new();
|
||||
t.prefs.edit(|p| p.last_brush = json!({"brush": {"opacity": 0.0}}));
|
||||
restore_brush(&mut t);
|
||||
assert_eq!(t.tools.brush.opacity, 0.01, "the remembered session brush");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_brushes_never_embed_large_bitmaps() {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -899,7 +899,9 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface {
|
||||
}
|
||||
|
||||
/// Image › Mode › RGB/Grayscale/CMYK/Lab through the CMS. Params: `profile` (destination,
|
||||
/// default the mode's working profile), `intent` (default relative), `bpc` (default true).
|
||||
/// default the mode's working profile; Indexed→RGB without an explicit profile instead expands
|
||||
/// the already-RGB storage without changing its encoded samples or ICC bytes), `intent`
|
||||
/// (default relative), `bpc` (default true).
|
||||
pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value> {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
if doc.mode == ColorMode::Multichannel && mode != ColorMode::Multichannel {
|
||||
@@ -908,8 +910,13 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value
|
||||
if doc.mode == mode && p.get("profile").is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let preserve_indexed_rgb = p.get("profile").and_then(Value::as_str).is_none()
|
||||
&& doc.mode == ColorMode::Indexed
|
||||
&& mode == ColorMode::Rgb
|
||||
&& doc.icc_profile.as_ref().is_none_or(|bytes| profile_from_bytes(bytes).ok().is_some_and(|p| p.color_space == ColorSpace::Rgb));
|
||||
let dst = match p.get("profile").and_then(Value::as_str) {
|
||||
Some(spec) => s.color.resolve(spec, Some(doc), Some(mode))?,
|
||||
None if preserve_indexed_rgb => document_profile(doc),
|
||||
None => s.color.working(mode),
|
||||
};
|
||||
if space_mode(dst.color_space)
|
||||
@@ -923,7 +930,16 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value
|
||||
}
|
||||
let intent = intent_or(p, s.color.settings.intent())?;
|
||||
let bpc = bool_param(p, "bpc", s.color.settings.bpc);
|
||||
s.edit("Mode Change", |doc, _| convert_document(doc, &dst, intent, bpc))?;
|
||||
if preserve_indexed_rgb {
|
||||
s.edit("Mode Change", |doc, _| {
|
||||
doc.mode = ColorMode::Rgb;
|
||||
doc.color_table = None;
|
||||
doc.duotone = None;
|
||||
Ok(())
|
||||
})?;
|
||||
} else {
|
||||
s.edit("Mode Change", |doc, _| convert_document(doc, &dst, intent, bpc))?;
|
||||
}
|
||||
Ok(json!({ "profile": dst.description, "intent": intent.id(), "bpc": bpc }))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! #11933: a Color Replacement click, a drag and a Shift+click segment each add one History state
|
||||
//! named "Color Replacement Tool", on a layer and on the Background with Lock transparent pixels;
|
||||
//! the command's label, which recorded actions show, is the same name.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn last(s: &Session) -> (usize, Option<String>) {
|
||||
let h = &s.active().unwrap().history;
|
||||
(h.past_len(), h.entries().last().map(|e| e.to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_click_stroke_and_segment_is_one_color_replacement_tool_state() {
|
||||
for on_background in [false, true] {
|
||||
let mut s = session();
|
||||
if !on_background {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap();
|
||||
} else {
|
||||
s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
|
||||
}
|
||||
for points in [json!([[20, 20]]), json!([[20, 50], [80, 50]]), json!([[80, 50], [80, 80]])] {
|
||||
let (n, _) = last(&s);
|
||||
s.execute("paint.colorReplacement", json!({"points": points, "size": 15})).unwrap();
|
||||
assert_eq!(last(&s), (n + 1, Some(COLOR_REPLACEMENT_STEP.to_string())), "{points} (background {on_background})");
|
||||
}
|
||||
}
|
||||
assert_eq!(COLOR_REPLACEMENT_STEP, "Color Replacement Tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_steps_use_the_same_name() {
|
||||
let spec = crate::commands::find("paint.colorReplacement").expect("the command");
|
||||
assert_eq!(spec.label, COLOR_REPLACEMENT_STEP);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! #11923: the Color Replacement tool refuses RGB/32, Grayscale, Bitmap and Indexed documents with
|
||||
//! the reference application's messages, painting nothing and adding no history; the live stroke
|
||||
//! is refused the same way. RGB/8, RGB/16, CMYK and Lab paint.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Size};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const DEPTH_32: &str =
|
||||
"Could not use the Color Replacement tool because it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit).";
|
||||
const NOT_FULL_COLOR: &str = "Could not use the Color Replacement tool because it only works in full color modes.";
|
||||
const INDEXED: &str = "Could not use the Color Replacement tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit).";
|
||||
|
||||
/// A session with one 64 × 64 document of `mode` and `depth`, filled red.
|
||||
fn session(mode: ColorMode, depth: SampleType) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.add_document(Document::with_background("t", Size::new(64, 64), mode, depth, Color::rgb(220.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0)), None);
|
||||
s.tools.foreground = [0.0, 0.0, 1.0, 1.0];
|
||||
s
|
||||
}
|
||||
|
||||
fn stroke() -> Value {
|
||||
json!({"points": [[20, 32], [44, 32]], "size": 20, "hardness": 100})
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn pixels(s: &Session) -> Vec<f32> {
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layers[0].surface().unwrap();
|
||||
(0..64).flat_map(|y| (0..64).flat_map(move |x| surf.pixel(x, y))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_modes_say_why_and_paint_nothing() {
|
||||
for (mode, depth, want) in [
|
||||
(ColorMode::Rgb, SampleType::F32, DEPTH_32),
|
||||
(ColorMode::Grayscale, SampleType::U8, NOT_FULL_COLOR),
|
||||
(ColorMode::Grayscale, SampleType::U16, NOT_FULL_COLOR),
|
||||
(ColorMode::Bitmap, SampleType::U8, NOT_FULL_COLOR),
|
||||
(ColorMode::Indexed, SampleType::U8, INDEXED),
|
||||
] {
|
||||
let mut s = session(mode, depth);
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let err = s.execute("paint.colorReplacement", stroke()).unwrap_err().to_string();
|
||||
assert_eq!(err, want, "{mode:?}/{depth:?}");
|
||||
assert_eq!(pixels(&s), before, "{mode:?}/{depth:?}: nothing painted");
|
||||
assert_eq!(steps(&s), n, "{mode:?}/{depth:?}: no history");
|
||||
// The live stroke previews nothing either.
|
||||
let live = live_color_replacement(&s, &stroke()).err().map(|e| e.to_string());
|
||||
assert_eq!(live.as_deref(), Some(want), "{mode:?}/{depth:?}: live");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_colour_documents_still_paint() {
|
||||
for (mode, depth) in
|
||||
[(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Cmyk, SampleType::U8), (ColorMode::Lab, SampleType::U8)]
|
||||
{
|
||||
let mut s = session(mode, depth);
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
s.execute("paint.colorReplacement", stroke()).unwrap_or_else(|e| panic!("{mode:?}/{depth:?}: {e}"));
|
||||
assert_ne!(pixels(&s), before, "{mode:?}/{depth:?}: painted");
|
||||
assert_eq!(steps(&s), n + 1, "{mode:?}/{depth:?}");
|
||||
assert!(live_color_replacement(&s, &stroke()).is_ok(), "{mode:?}/{depth:?}: live");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! #11763: each Count tool edit gets the reference application's History name. A `count.setGroup`
|
||||
//! that changes exactly one field takes that field's name; several fields at once, or a switch of
|
||||
//! the current group, keep "Count Group Options".
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 100 × 100 document with one count group holding one marker at 10,10 (its first state is
|
||||
/// "Add Count Object").
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
/// Run `id` and return the History name of the one step it added.
|
||||
fn named(s: &mut Session, id: &str, p: Value) -> String {
|
||||
let before = steps(s);
|
||||
s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(steps(s), before + 1, "{id} {p} adds exactly one step");
|
||||
s.active().unwrap().history.undo_label().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_edits_are_add_drag_and_delete_count_object() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
// The first click makes "Count Group 1" too, still one "Add Count Object" step.
|
||||
assert_eq!(named(&mut s, "count.add", json!({"x": 10, "y": 10})), "Add Count Object");
|
||||
assert_eq!(named(&mut s, "count.add", json!({"x": 40, "y": 40})), "Add Count Object");
|
||||
assert_eq!(named(&mut s, "count.move", json!({"x": 10, "y": 10, "to": [20, 20]})), "Drag Count Object");
|
||||
assert_eq!(named(&mut s, "count.remove", json!({"x": 40, "y": 40})), "Delete Count Object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_edits_keep_or_take_their_names() {
|
||||
let mut s = session();
|
||||
assert_eq!(named(&mut s, "count.newGroup", json!({"name": "Leaves"})), "Add Count Group");
|
||||
assert_eq!(named(&mut s, "count.clear", json!({})), "Clear Count");
|
||||
assert_eq!(named(&mut s, "count.deleteGroup", json!({})), "Delete Count Group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_field_change_uses_that_fields_name() {
|
||||
for (p, want) in [
|
||||
(json!({"name": "Seeds"}), "Set Count Group Name"),
|
||||
(json!({"visible": false}), "Set Count Group Visibility"),
|
||||
(json!({"color": "#204080"}), "Set Count Draw Color"),
|
||||
(json!({"markerSize": 5}), "Set Count Marker Size"),
|
||||
(json!({"labelSize": 20}), "Set Count Font Size"),
|
||||
] {
|
||||
let mut s = session();
|
||||
assert_eq!(named(&mut s, "count.setGroup", p.clone()), want, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_fields_at_once_keep_count_group_options() {
|
||||
let mut s = session();
|
||||
assert_eq!(named(&mut s, "count.setGroup", json!({"markerSize": 5, "labelSize": 20})), "Count Group Options");
|
||||
// A field sent with its current value doesn't count as a change.
|
||||
let mut s = session();
|
||||
let name = s.execute("image.analysis.countTool", json!({})).unwrap()["groups"][0]["name"].clone();
|
||||
assert_eq!(named(&mut s, "count.setGroup", json!({"name": name, "markerSize": 6})), "Set Count Marker Size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_the_current_group_keeps_todays_name() {
|
||||
let mut s = session();
|
||||
s.execute("count.newGroup", json!({"name": "Two"})).unwrap();
|
||||
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0})), "Count Group Options", "only `group`");
|
||||
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 1, "active": true})), "Count Group Options", "`active: true`, no field change");
|
||||
// A field change together with a switch is two changes: today's name too.
|
||||
assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0, "active": true, "visible": false})), "Count Group Options");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_change_adds_no_step_and_bad_params_stay_errors() {
|
||||
let mut s = session();
|
||||
let before = steps(&s);
|
||||
// The current group's own values: nothing changes, no step.
|
||||
s.execute("count.setGroup", json!({"group": 0})).unwrap();
|
||||
assert_eq!(steps(&s), before);
|
||||
for (id, p) in [
|
||||
("count.setGroup", json!({"group": 9, "name": "x"})),
|
||||
("count.setGroup", json!({"markerSize": 2.5})),
|
||||
("count.move", json!({"x": 90, "y": 90, "to": [1, 1]})),
|
||||
("count.remove", json!({"x": 90, "y": 90})),
|
||||
("count.add", json!({"x": 1, "y": 1, "group": 7})),
|
||||
] {
|
||||
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
|
||||
assert_eq!(steps(&s), before, "{id} {p} adds no step");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use std::cell::RefCell;
|
||||
|
||||
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell};
|
||||
use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten};
|
||||
use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask};
|
||||
use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh};
|
||||
use photocraft_algo::transform::Interp;
|
||||
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface};
|
||||
use photocraft_color::PixelFormat;
|
||||
@@ -86,6 +86,51 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result<PuppetWarp> {
|
||||
Ok(PuppetWarp { pins, mode, density, expansion })
|
||||
}
|
||||
|
||||
/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the
|
||||
/// PSD importer reads no more.
|
||||
const MAX_STORED_VERTS: usize = 1 << 16;
|
||||
|
||||
/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts",
|
||||
/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of
|
||||
/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its
|
||||
/// pins are no longer the ones it was solved for, or a solver setting is set (imported params
|
||||
/// set none).
|
||||
pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> {
|
||||
let m = p.get("mesh")?;
|
||||
if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let points = |k: &str| -> Option<Vec<[f64; 2]>> {
|
||||
let v = m.get(k)?.as_array()?;
|
||||
if v.len() > MAX_STORED_VERTS {
|
||||
return None;
|
||||
}
|
||||
v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect()
|
||||
};
|
||||
let (verts, deformed) = (points("verts")?, points("deformed")?);
|
||||
let tris = m.get("tris")?.as_array()?;
|
||||
if tris.len() > 2 * MAX_STORED_VERTS {
|
||||
return None;
|
||||
}
|
||||
let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::<Option<_>>()?;
|
||||
Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed))
|
||||
}
|
||||
|
||||
/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another
|
||||
/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't
|
||||
/// fit a smart object moved, transformed or warped since, so its pins are solved instead.
|
||||
pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> {
|
||||
let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json()));
|
||||
if !stale {
|
||||
return std::borrow::Cow::Borrowed(params);
|
||||
}
|
||||
let mut p = params.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.remove("mesh");
|
||||
}
|
||||
std::borrow::Cow::Owned(p)
|
||||
}
|
||||
|
||||
/// Perspective Warp params (planes, after the optional straighten).
|
||||
pub fn perspective_params(cmd: &str, p: &Value) -> Result<Vec<Plane>> {
|
||||
let mut planes: Vec<Plane> = match p.get("planes") {
|
||||
@@ -182,7 +227,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect)
|
||||
Some(liquify_surface(surf, &strokes, cell, canvas))
|
||||
}
|
||||
// A stored warp too far to render leaves the smart object as it is (#1019).
|
||||
PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())),
|
||||
PUPPET | PUPPET_SMART => {
|
||||
// A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that
|
||||
// can't be drawn leaves the pins to solve.
|
||||
let b = surf.content_bounds();
|
||||
if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty())
|
||||
&& let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params))
|
||||
{
|
||||
return Some(out);
|
||||
}
|
||||
Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone()))
|
||||
}
|
||||
PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -473,3 +473,71 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() {
|
||||
// A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole
|
||||
// move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins
|
||||
// change; then the pins are solved.
|
||||
let surf = active_surface(&session(8));
|
||||
let canvas = Rect::new(0, 0, 80, 60);
|
||||
let pins = json!([{"src": [30, 25], "dst": [36, 29]}]);
|
||||
let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]];
|
||||
let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect();
|
||||
let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]});
|
||||
let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy);
|
||||
let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh});
|
||||
let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap();
|
||||
assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41));
|
||||
assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0);
|
||||
// Pins edited after import: the stored mesh no longer applies.
|
||||
let mut edited = p.clone();
|
||||
edited["mesh"]["pins"][0]["dst"] = json!([40, 29]);
|
||||
let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap();
|
||||
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44));
|
||||
// So does a solver setting set after import (imported params set none).
|
||||
for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] {
|
||||
let mut q = p.clone();
|
||||
q[k] = v;
|
||||
let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap();
|
||||
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}");
|
||||
}
|
||||
// A malformed mesh is ignored the same way.
|
||||
for bad in [
|
||||
json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}),
|
||||
json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q["mesh"] = bad;
|
||||
let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap();
|
||||
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() {
|
||||
// The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the
|
||||
// smart object is moved, transformed or warped, its pins are solved instead.
|
||||
let surf = active_surface(&session(8));
|
||||
let canvas = Rect::new(0, 0, 80, 60);
|
||||
let pins = json!([{"src": [30, 25], "dst": [36, 29]}]);
|
||||
let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]];
|
||||
let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect();
|
||||
let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() };
|
||||
let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None);
|
||||
let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()});
|
||||
let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh});
|
||||
sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true });
|
||||
let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds();
|
||||
assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh");
|
||||
let mut placed = sm.clone();
|
||||
placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] };
|
||||
assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved");
|
||||
let mut warped = sm.clone();
|
||||
warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0]));
|
||||
assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved");
|
||||
// A mesh with no placement recorded is treated the same way.
|
||||
let mut bare = sm.clone();
|
||||
bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement");
|
||||
assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved");
|
||||
}
|
||||
@@ -989,12 +989,16 @@ fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result<Option<f64>>
|
||||
let px = match (v.as_f64(), v.as_str()) {
|
||||
(Some(n), _) => n,
|
||||
(None, Some(text)) => {
|
||||
let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| {
|
||||
EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %"))
|
||||
})?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let extent = if vertical { doc.size.width } else { doc.size.height };
|
||||
unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch())
|
||||
let per_inch = s.prefs().units_and_rulers.point_size.per_inch();
|
||||
// A length, or arithmetic on lengths as Photoshop's field takes ("100% / 3", #10440).
|
||||
let to_px = |n: f64, unit: crate::prefs::Unit| unit.to_px(n, doc.resolution_dpi as f64, extent as f64, per_inch);
|
||||
crate::prefs::Unit::eval_length(text, crate::prefs::Unit::Pixels, to_px).ok_or_else(|| {
|
||||
EngineError::Other(format!(
|
||||
"`position` {text:?} is not a length: give a number of pixels, a number with px, in, cm, mm, pt, pc or %, or arithmetic on them (+ - * /)"
|
||||
))
|
||||
})?
|
||||
}
|
||||
_ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())),
|
||||
};
|
||||
@@ -1263,8 +1267,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_clip,
|
||||
|s, p| paste(s, p, true)
|
||||
),
|
||||
spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
|
||||
spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
|
||||
spec!("layer.new.layerViaCopy", "Layer Via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
|
||||
spec!("layer.new.layerViaCut", "Layer Via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
|
||||
spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)),
|
||||
spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")),
|
||||
spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")),
|
||||
|
||||
@@ -1292,6 +1292,56 @@ fn guide_positions_take_photoshop_units() {
|
||||
assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0);
|
||||
}
|
||||
|
||||
/// #10440: the position takes arithmetic as Photoshop's New Guide field does. Measured in
|
||||
/// Photoshop 2025 on a 600 × 400, 72 ppi document: "100% / 3" → 200, "100% - 50px" → 550,
|
||||
/// "300 / 2" → 150 (pixels), "1in + 10" → 792 (the bare 10 is in the first operand's unit).
|
||||
#[test]
|
||||
fn guide_positions_take_arithmetic() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
|
||||
for (text, px) in [
|
||||
("100% / 3", 200.0),
|
||||
("100% - 50px", 550.0),
|
||||
("300 / 2", 150.0),
|
||||
("1in + 10", 792.0),
|
||||
("2 * 50 px", 100.0),
|
||||
("10 + 2 * 3", 16.0),
|
||||
("-50px + 100", 50.0),
|
||||
] {
|
||||
assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}");
|
||||
}
|
||||
// Horizontal guides take percent of the height.
|
||||
assert!((new_guide(&mut s, "horizontal", json!("50% + 10px")) - 210.0).abs() < 1e-6);
|
||||
for bad in ["100% /", "1in * 2in", "10 / 0", "10 / 2px", "5 ^ 2", "+", "50% 3"] {
|
||||
let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad})).unwrap_err();
|
||||
assert!(err.to_string().contains("not a length"), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Unicode minus (U+2212, what some keyboards and pasted text give) is a minus, as in the
|
||||
/// numeric fields' own evaluator.
|
||||
#[test]
|
||||
fn guide_position_arithmetic_takes_the_unicode_minus() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
|
||||
for (text, px) in [("100% − 50px", 550.0), ("−50px + 100", 50.0), ("1in − −10", 792.0), ("−100", -100.0)] {
|
||||
assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Text longer than the numeric fields' 1024-byte cap is refused before it is tokenised.
|
||||
#[test]
|
||||
fn guide_position_arithmetic_is_capped_at_1024_bytes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
|
||||
let fits = format!("{}100", "0 + ".repeat(255));
|
||||
assert_eq!(fits.len(), 1023);
|
||||
assert_eq!(new_guide(&mut s, "vertical", json!(fits)), 100.0);
|
||||
let long = format!("{}100", "0 + ".repeat(256));
|
||||
let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": long})).unwrap_err();
|
||||
assert!(err.to_string().contains("not a length"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() {
|
||||
let mut s = Session::new();
|
||||
@@ -1381,6 +1431,26 @@ fn editing_a_guide_changes_what_is_given_in_one_undoable_step() {
|
||||
assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff]));
|
||||
}
|
||||
|
||||
/// #10440: Edit Selected Guides reads its position as New Guide does, arithmetic included; % is of
|
||||
/// the new orientation's extent.
|
||||
#[test]
|
||||
fn editing_a_guide_takes_arithmetic_in_its_position() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap();
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "100% / 4"})).unwrap();
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "1in + 1"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.guides.vertical, vec![100.0, 200.0]);
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "newOrientation": "horizontal", "position": "50% − 10px"})).unwrap();
|
||||
let g = &s.active().unwrap().doc.guides;
|
||||
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![200.0], vec![90.0]));
|
||||
let before = g.clone();
|
||||
let err = s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "1in * 2in"})).unwrap_err();
|
||||
assert!(err.to_string().contains("not a length"), "{err}");
|
||||
assert_eq!(s.active().unwrap().doc.guides, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_guide_edits_are_refused_without_a_history_step() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -313,7 +313,7 @@ pub use content_aware::{CafPlan, FillEdit, MAX_CAF_EDITS, MAX_CAF_POINTS, SelOp,
|
||||
use content_aware::{can_caf, content_aware_fill, delete_and_fill};
|
||||
pub use content_aware_scale::ContentAwareScalePreview;
|
||||
use content_aware_scale::content_aware_scale;
|
||||
pub use fade::fade_surface;
|
||||
pub use fade::{FadePreview, fade_preview, fade_surface};
|
||||
use fade::{can_fade, fade};
|
||||
pub use find_replace::find_matches;
|
||||
use find_replace::{find_replace, has_type_layer};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of,
|
||||
BlendMode, Document, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of,
|
||||
surface_of_mut, to_rgba,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,18 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// What Edit › Fade with `p` blends: the layer, mask or channel of the last step, its pixels
|
||||
/// before it, the area to blend, the mode and the opacity.
|
||||
struct FadeJob {
|
||||
target: FadeTarget,
|
||||
label: String,
|
||||
before: Surface,
|
||||
area: Rect,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
fn fade_job(s: &Session, p: &Value) -> Result<FadeJob> {
|
||||
let cmd = "edit.fade";
|
||||
let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?;
|
||||
let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0;
|
||||
@@ -82,12 +93,45 @@ pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A uniform mask can change every pixel without storing a single tile. In that case
|
||||
// the content bounds are empty, but Fade must still blend across the canvas.
|
||||
let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) };
|
||||
let label = format!("Fade {}", src.label);
|
||||
s.edit(&label, |doc, _| {
|
||||
let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
|
||||
fade_surface(&before, surf, area, mode, opacity)?;
|
||||
surf.prune();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()}))
|
||||
Ok(FadeJob { target: src.target, label: src.label, before, area, mode, opacity })
|
||||
}
|
||||
|
||||
fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> {
|
||||
let surf = surface_of_mut(doc, j.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
|
||||
fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?;
|
||||
surf.prune();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let j = fade_job(s, p)?;
|
||||
let label = format!("Fade {}", j.label);
|
||||
s.edit(&label, |doc, _| apply_fade(doc, &j))?;
|
||||
Ok(json!({"faded": j.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()}))
|
||||
}
|
||||
|
||||
/// Edit › Fade with given params, captured from the session without a history step: the Fade
|
||||
/// dialog's live preview (#8230). [`FadePreview::render`] does the blend and can run off the
|
||||
/// session (the preview worker). The document copy shares its tiles with the session's, and
|
||||
/// only the tiles in the faded area are blended and rewritten.
|
||||
pub struct FadePreview {
|
||||
doc: Document,
|
||||
job: FadeJob,
|
||||
}
|
||||
|
||||
impl FadePreview {
|
||||
/// The active document as Edit › Fade would leave it.
|
||||
pub fn render(mut self) -> Result<Document> {
|
||||
apply_fade(&mut self.doc, &self.job)?;
|
||||
Ok(self.doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What Edit › Fade with `p` would do to the active document. `Err` when Fade isn't available
|
||||
/// or `p` is invalid.
|
||||
pub fn fade_preview(s: &Session, p: &Value) -> Result<FadePreview> {
|
||||
can_fade(s).map_err(|e| bad("edit.fade", e))?;
|
||||
let job = fade_job(s, p)?;
|
||||
let doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc);
|
||||
Ok(FadePreview { doc, job })
|
||||
}
|
||||
@@ -23,6 +23,33 @@ fn active(s: &Session) -> &Layer {
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a
|
||||
/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad
|
||||
/// mode it is an error, not a panic.
|
||||
#[test]
|
||||
fn fade_preview_matches_fade_without_a_step() {
|
||||
for depth in [8, 16, 32] {
|
||||
assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet");
|
||||
for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] {
|
||||
let mut s = session(depth);
|
||||
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision);
|
||||
let preview = fade_preview(&s, &p).and_then(FadePreview::render).unwrap();
|
||||
assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step");
|
||||
assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched");
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5);
|
||||
s.execute("edit.fade", p.clone()).unwrap();
|
||||
assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies");
|
||||
}
|
||||
let mut s = session(depth);
|
||||
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err());
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_blends_last_step_at_all_depths() {
|
||||
for depth in [8, 16, 32] {
|
||||
@@ -119,7 +146,11 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() {
|
||||
assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted");
|
||||
let expected = was + (1.0 - 2.0 * was) * k;
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// The Fade dialog's preview (#8230) shows the channel as OK leaves it.
|
||||
let preview = fade_preview(&s, &json!({"opacity": opacity})).and_then(FadePreview::render).unwrap();
|
||||
let shown = if quick_mask { preview.quick_mask.as_ref() } else { preview.channels.first() }.unwrap().surface.pixel(5, 5)[0];
|
||||
s.execute("edit.fade", json!({"opacity": opacity})).unwrap();
|
||||
assert_eq!(shown, channel(&s, 5, 5), "depth {depth}, quick mask {quick_mask}, {opacity} %: the preview is what OK applies");
|
||||
for (x, y) in [(0, 0), (5, 5), (63, 47)] {
|
||||
let v = channel(&s, x, y);
|
||||
assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}");
|
||||
|
||||
@@ -140,7 +140,7 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet
|
||||
let bs = BgEraseSettings {
|
||||
sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?,
|
||||
limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?,
|
||||
tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0,
|
||||
tolerance: number(p, CMD, "tolerance", 50.0, 1.0..=100.0)? as f32 / 100.0,
|
||||
protect_foreground: flag(p, "protectForegroundColor", false),
|
||||
foreground: s.tools.foreground,
|
||||
background: s.tools.background,
|
||||
@@ -267,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Background Eraser",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##,
|
||||
params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":1..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##,
|
||||
enabled: has_pixel_layer,
|
||||
run: background_eraser,
|
||||
journal: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ mod batch_run;
|
||||
mod export;
|
||||
mod info;
|
||||
mod open;
|
||||
mod open_as_smart_object;
|
||||
mod paths;
|
||||
mod place_steps;
|
||||
mod processing;
|
||||
@@ -50,6 +51,8 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_
|
||||
pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults};
|
||||
pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace};
|
||||
use open::{approve_memory, close_all, close_others, open_as, place, revert};
|
||||
pub use open_as_smart_object::admit_as_smart_object;
|
||||
use open_as_smart_object::open_as_smart_object;
|
||||
use paths::is_file;
|
||||
#[cfg(test)]
|
||||
use paths::openable_name;
|
||||
@@ -285,6 +288,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
native,
|
||||
open_as
|
||||
),
|
||||
spec!(
|
||||
"file.openAsSmartObject",
|
||||
"Open as Smart Object…",
|
||||
&["File"],
|
||||
None,
|
||||
r##"{"path":str,"confirmDamage":bool=false (a damaged PSD waits for an answer, as File > Open's does)} (opens the file as a new untitled document named "<file name without its last extension>-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color, smartObject} (or {held, name, warnings, color, damaged, smartObject} while a prompt waits)"##,
|
||||
native,
|
||||
open_as_smart_object
|
||||
),
|
||||
// One-shot permission for one file, not something to replay later: never journaled or
|
||||
// recorded in an action, since replaying it in another session/on another file would
|
||||
// grant memory use nobody approved (#10897). It also releases the queued source on both
|
||||
@@ -421,6 +433,10 @@ mod tests;
|
||||
#[path = "file_cmds/place_job_tests.rs"]
|
||||
mod place_job_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_cmds/open_as_smart_object_tests.rs"]
|
||||
mod open_as_smart_object_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod aces_exr_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -74,9 +74,15 @@ fn import_result(name: &str, bytes: &[u8]) -> Result<photocraft_io::ImportResult
|
||||
/// The one import path every caller shares: allocation checkpoint, native `.pcraft` and format
|
||||
/// dispatch through `photocraft_io::import_with_options`, typed memory refusals, and the
|
||||
/// preview-only refusal wherever the import came from.
|
||||
fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<photocraft_io::ImportResult> {
|
||||
pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<photocraft_io::ImportResult> {
|
||||
import_result_for_open(name, bytes, opts).map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// [`import_result_with`], also telling whether a PSD/PSB had damaged data it recovered from, as
|
||||
/// File › Open's job learns it (`photocraft_io::import_for_open`), so an interactive caller can ask.
|
||||
pub(super) fn import_result_for_open(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<(photocraft_io::ImportResult, bool)> {
|
||||
crate::allocation::checkpoint("importing document")?;
|
||||
let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?;
|
||||
let (r, damaged) = photocraft_io::import_for_open(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?;
|
||||
// Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
|
||||
// Open has its own warning-preserving path; never silently place or process a thumbnail.
|
||||
if r.preview_only {
|
||||
@@ -84,7 +90,7 @@ fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOpti
|
||||
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
|
||||
)));
|
||||
}
|
||||
Ok(r)
|
||||
Ok((r, damaged))
|
||||
}
|
||||
|
||||
// ---------- close / revert / save a copy / open as ----------
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's
|
||||
//! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer.
|
||||
//!
|
||||
//! The layer is built as File › Place Embedded builds one (an embedded smart object at the file's own
|
||||
//! size and position `0, 0`) and put into the document before it is admitted, so the opening
|
||||
//! snapshot already shows it.
|
||||
|
||||
use super::open::import_result_for_open;
|
||||
use super::paths::{file_name, read_file, stem, untitled_name};
|
||||
use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param};
|
||||
use crate::layer_place::Place;
|
||||
|
||||
const CMD: &str = "file.openAsSmartObject";
|
||||
|
||||
/// The history step's name.
|
||||
const LABEL: &str = "Open As Smart Object";
|
||||
|
||||
/// Opens the file as a document holding one embedded smart object. The read, decode and flatten
|
||||
/// run on the job's worker (inline under `execute`); the document is built, given File › Open's
|
||||
/// colour policy and admitted, or held for its profile prompt, when the job is applied. The reply
|
||||
/// has the shape of File › Open's job: `{document, docId, name, layer, warnings, color}`, or
|
||||
/// `{held, name, warnings, color, smartObject}` while the profile prompt waits.
|
||||
pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path", CMD)?.to_string();
|
||||
let name = file_name(&path);
|
||||
let opts = crate::jobs::import_options(s.prefs());
|
||||
// An interactive caller (the app) asks before opening a PSD that had damaged data, as File > Open does.
|
||||
let confirm_damage = p.get("confirmDamage").and_then(Value::as_bool).unwrap_or(false);
|
||||
crate::jobs::run(
|
||||
s,
|
||||
&format!("Opening {name}"),
|
||||
false,
|
||||
move |ctx| {
|
||||
ctx.progress(0.0, "Reading");
|
||||
let bytes = read_file(&path)?;
|
||||
ctx.check()?;
|
||||
ctx.progress(0.05, "Decoding");
|
||||
let (r, damaged) = import_result_for_open(&name, &bytes, &opts)?;
|
||||
ctx.check()?;
|
||||
ctx.progress(0.9, "Flattening");
|
||||
// The file as one image: the smart object's rendered contents.
|
||||
let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?;
|
||||
Ok((path, name, Arc::new(bytes), r, pixels, confirm_damage && damaged))
|
||||
},
|
||||
|s, (path, name, bytes, r, pixels, damaged)| apply(s, &path, &name, bytes, r, pixels, damaged),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
file: &str,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
r: photocraft_io::ImportResult,
|
||||
pixels: photocraft_raster::Surface,
|
||||
damaged: bool,
|
||||
) -> Result<Value> {
|
||||
let warnings = r.warnings;
|
||||
let mut src = r.document;
|
||||
super::tag_exr_colorspace(s, file, &mut src);
|
||||
super::raw_defaults(s, &warnings, &mut src);
|
||||
let layer_name = stem(path);
|
||||
// "<name>-1", the next free number while a document with that name is open.
|
||||
let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n) || s.held_open_named(n));
|
||||
|
||||
let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth);
|
||||
doc.resolution_dpi = src.resolution_dpi;
|
||||
doc.icc_profile = src.icc_profile.clone();
|
||||
doc.color_table = src.color_table.clone();
|
||||
doc.duotone = src.duotone.clone();
|
||||
|
||||
// Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it.
|
||||
let source = SmartSource::Embedded { file_name: file.to_string(), bytes };
|
||||
let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels));
|
||||
let layer = Layer::new(layer_name, LayerContent::Smart(so));
|
||||
let layer_id = layer.id;
|
||||
crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable);
|
||||
|
||||
// File › Open's completion: a raw has no embedded profile to ask about; otherwise the profile
|
||||
// prompt holds the document, and without a question the Color Settings policy is applied.
|
||||
let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
|
||||
let question = s.color.open_question(&doc).filter(|_| !raw);
|
||||
if damaged || question.is_some() {
|
||||
let held = s.hold_open(doc, false);
|
||||
return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "damaged": damaged, "smartObject": true}));
|
||||
}
|
||||
let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag);
|
||||
let index = admit_as_smart_object(s, doc);
|
||||
let id = s.documents().get(index).map(|st| st.doc.id.0);
|
||||
// Admission numbers the document again, so the reply reads the name it was admitted under.
|
||||
let name = s.documents().get(index).map_or(name, |st| st.doc.name.clone());
|
||||
Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true}))
|
||||
}
|
||||
|
||||
/// Admits a document built by Open as Smart Object (one smart object layer) as an untitled
|
||||
/// document: the opening snapshot already shows the placed layer, is named after it, and the
|
||||
/// history is the single state "Open As Smart Object", as File › Open's is one state.
|
||||
pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize {
|
||||
let layer_name = doc.layers.first().map(|l| l.name.clone());
|
||||
// The number was free when the file was read, but documents held for their prompts did not count
|
||||
// then, and one of them may have been cancelled since: take the first free one now, as each
|
||||
// document is admitted.
|
||||
if let Some(base) = &layer_name {
|
||||
doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n));
|
||||
}
|
||||
// Untitled: no path, so Save asks where to save.
|
||||
let index = s.add_document(doc, None);
|
||||
if let Some(st) = s.active_mut() {
|
||||
if let (Some(first), Some(name)) = (st.snapshots.iter_mut().find(|snap| snap.id == 0), layer_name) {
|
||||
first.name = name;
|
||||
}
|
||||
st.history.set_current_label(LABEL);
|
||||
}
|
||||
index
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! File › Open as Smart Object… (#11678): a file opens as a new untitled document holding one
|
||||
//! embedded smart object layer.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-open-as-so-{}-{name}-{id}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// A small PNG with a gradient, so the pixels differ from place to place.
|
||||
fn write_png(dir: &str, name: &str, w: u32, h: u32) -> String {
|
||||
let px: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let path = join(dir, name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// A two-layer 16-bit PSD at 300 ppi tagged with Display P3, made with the engine's own save.
|
||||
fn write_psd16(dir: &str, name: &str, w: u32, h: u32) -> String {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap();
|
||||
let path = join(dir, name);
|
||||
save_doc(&s.active().unwrap().doc, &path, None).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn imported(path: &str) -> Document {
|
||||
photocraft_io::import(&file_name(path), &std::fs::read(path).unwrap()).unwrap().document
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.documents().iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The one layer of the active document, which must be an embedded smart object.
|
||||
fn embedded(s: &Session) -> (String, String, Arc<Vec<u8>>) {
|
||||
let d = active(s);
|
||||
assert_eq!(d.layers.len(), 1, "one layer, not a Background plus a layer");
|
||||
let layer = &d.layers[0];
|
||||
match &layer.content {
|
||||
LayerContent::Smart(so) => match &so.source {
|
||||
SmartSource::Embedded { file_name, bytes } => (layer.name.clone(), file_name.clone(), Arc::clone(bytes)),
|
||||
other => panic!("expected an embedded smart object, got {other:?}"),
|
||||
},
|
||||
_ => panic!("the layer is not a smart object"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_opens_as_an_untitled_document_with_one_embedded_smart_object() {
|
||||
let dir = tmp("png");
|
||||
let path = write_png(&dir, "a_test.png", 64, 48);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["name"], "a_test-1", "{r}");
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.name, "a_test-1");
|
||||
assert_eq!(st.path, None, "untitled: Save asks where to save");
|
||||
|
||||
let file = imported(&path);
|
||||
let d = &st.doc;
|
||||
assert_eq!((d.size.width, d.size.height), (64, 48));
|
||||
assert_eq!(d.resolution_dpi, file.resolution_dpi);
|
||||
assert_eq!((d.mode, d.depth), (file.mode, file.depth));
|
||||
assert_eq!(d.icc_profile, file.icc_profile);
|
||||
|
||||
let (layer, original, bytes) = embedded(&s);
|
||||
assert_eq!(layer, "a_test", "named after the file without its extension");
|
||||
assert_eq!(original, "a_test.png", "Edit Contents opens the original file name");
|
||||
assert_eq!(*bytes, std::fs::read(&path).unwrap(), "the file itself is embedded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_smart_object_shows_the_file() {
|
||||
let dir = tmp("pixels");
|
||||
let path = write_png(&dir, "grad.png", 32, 16);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let want = photocraft_compose::render(&imported(&path), Rect::new(0, 0, 32, 16)).px.clone();
|
||||
let got = photocraft_compose::render(active(&s), Rect::new(0, 0, 32, 16)).px.clone();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() {
|
||||
let dir = tmp("psd");
|
||||
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
|
||||
let mut s = Session::new();
|
||||
// The files were measured in the working spaces, where nothing asks; Preserve keeps Display P3.
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let file = imported(&path);
|
||||
assert_eq!(file.resolution_dpi, 300.0);
|
||||
assert!(file.icc_profile.is_some());
|
||||
let d = active(&s);
|
||||
assert_eq!(d.name, "c.layered-1", "only the last extension is dropped");
|
||||
assert_eq!((d.size.width, d.size.height), (40, 30));
|
||||
assert_eq!(d.resolution_dpi, 300.0);
|
||||
assert_eq!((d.mode, d.depth), (file.mode, file.depth));
|
||||
assert_eq!(d.depth, photocraft_doc::SampleType::U16);
|
||||
assert_eq!(d.icc_profile, file.icc_profile);
|
||||
let (layer, original, _) = embedded(&s);
|
||||
assert_eq!((layer.as_str(), original.as_str()), ("c.layered", "c.layered.psd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_the_single_state_open_as_smart_object() {
|
||||
let dir = tmp("history");
|
||||
let path = write_png(&dir, "a_test.png", 16, 16);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.history.entries(), ["Open As Smart Object"], "one state, as File > Open has");
|
||||
assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opening_snapshot_shows_the_placed_smart_object() {
|
||||
let dir = tmp("snapshot");
|
||||
let path = write_png(&dir, "a_test.png", 16, 16);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let first = st.snapshots.first().unwrap();
|
||||
assert_eq!(first.doc.layers.len(), 1, "snapshot 0 already holds the smart object");
|
||||
assert!(matches!(first.doc.layers[0].content, LayerContent::Smart(_)));
|
||||
assert_eq!(first.layers.active, Some(first.doc.layers[0].id));
|
||||
assert_eq!(st.active_layer, Some(first.doc.layers[0].id));
|
||||
assert_eq!(st.history.entries(), ["Open As Smart Object"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_number_is_the_next_free_one_and_starts_again_after_they_close() {
|
||||
let dir = tmp("numbers");
|
||||
let path = write_png(&dir, "a_test.png", 8, 8);
|
||||
let mut s = Session::new();
|
||||
for _ in 0..3 {
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
}
|
||||
assert_eq!(names(&s), ["a_test-1", "a_test-2", "a_test-3"]);
|
||||
while !s.documents().is_empty() {
|
||||
s.close(0);
|
||||
}
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(names(&s), ["a_test-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_open_of_the_same_file_is_unchanged() {
|
||||
let dir = tmp("plain");
|
||||
let path = write_png(&dir, "a_test.png", 8, 8);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap();
|
||||
assert_eq!(names(&s), ["a_test-1", "a_test.png"]);
|
||||
assert!(matches!(active(&s).layers[0].content, LayerContent::Raster(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_files_open_one_document_each() {
|
||||
let dir = tmp("several");
|
||||
let a = write_png(&dir, "a_test.png", 8, 8);
|
||||
let b = write_psd16(&dir, "d_other.psd", 12, 10);
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
|
||||
s.execute("file.openAsSmartObject", json!({"path": a})).unwrap();
|
||||
s.execute("file.openAsSmartObject", json!({"path": b})).unwrap();
|
||||
assert_eq!(names(&s), ["a_test-1", "d_other-1"]);
|
||||
for st in s.documents() {
|
||||
assert_eq!(st.doc.layers.len(), 1);
|
||||
assert!(matches!(st.doc.layers[0].content, LayerContent::Smart(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_unreadable_file_is_an_error_not_a_crash() {
|
||||
let dir = tmp("bad");
|
||||
let junk = join(&dir, "notes.png");
|
||||
std::fs::write(&junk, b"this is not an image").unwrap();
|
||||
let text = join(&dir, "readme.txt");
|
||||
std::fs::write(&text, b"hello").unwrap();
|
||||
let mut s = Session::new();
|
||||
for path in [join(&dir, "missing.png"), junk, text] {
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path}));
|
||||
assert!(r.is_err(), "{path}: {r:?}");
|
||||
}
|
||||
assert!(s.documents().is_empty(), "a failed open leaves no document behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let mut s = Session::new();
|
||||
for params in [json!({}), json!({"path": 7}), json!({"path": null})] {
|
||||
let r = s.execute("file.openAsSmartObject", params.clone());
|
||||
assert!(r.is_err(), "{params}: {r:?}");
|
||||
}
|
||||
assert!(s.documents().is_empty());
|
||||
}
|
||||
|
||||
fn job(r: crate::jobs::Started) -> crate::jobs::JobId {
|
||||
match r {
|
||||
crate::jobs::Started::Job(id) => id,
|
||||
crate::jobs::Started::Done(v) => panic!("expected a background job, ran inline: {v}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until job `id` ends (or 60 s pass); returns its event.
|
||||
fn wait_event(s: &mut Session, id: crate::jobs::JobId) -> crate::jobs::JobEvent {
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) {
|
||||
return e;
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(60), "job {id:?} did not finish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_import_runs_as_a_job_when_started() {
|
||||
let dir = tmp("job");
|
||||
let path = write_png(&dir, "a_test.png", 32, 24);
|
||||
let mut s = Session::new();
|
||||
let id = job(s.start("file.openAsSmartObject", json!({"path": path})).unwrap());
|
||||
assert!(s.documents().is_empty(), "nothing is admitted before the job is applied");
|
||||
let e = wait_event(&mut s, id);
|
||||
let crate::jobs::JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") };
|
||||
assert!(v["docId"].is_u64(), "{v}");
|
||||
assert_eq!(v["name"], "a_test-1", "{v}");
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
embedded(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colour_policy_matches_file_open() {
|
||||
let dir = tmp("policy");
|
||||
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
|
||||
let settings = json!({"askOnMismatch": false, "policyRgb": "convert"});
|
||||
let mut open = Session::new();
|
||||
open.execute("edit.colorSettings", settings.clone()).unwrap();
|
||||
open.execute("file.openAs", json!({"path": path})).unwrap();
|
||||
let mut so = Session::new();
|
||||
so.execute("edit.colorSettings", settings).unwrap();
|
||||
so.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let (a, b) = (active(&open), active(&so));
|
||||
assert_eq!(a.icc_profile, b.icc_profile, "Convert is applied as File > Open applies it");
|
||||
assert_eq!((a.mode, a.depth, a.size), (b.mode, b.depth, b.size));
|
||||
assert_eq!(a.resolution_dpi, b.resolution_dpi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_when_opening_holds_the_document_like_file_open() {
|
||||
let dir = tmp("ask");
|
||||
let path = write_psd16(&dir, "c.layered.psd", 40, 30);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["color"]["ask"], true, "{r}");
|
||||
assert_eq!(r["smartObject"], true, "{r}");
|
||||
assert!(r["held"].is_u64(), "{r}");
|
||||
assert!(s.documents().is_empty(), "held for the profile prompt, not admitted");
|
||||
let (doc, _) = s.take_held_open(r["held"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!(doc.name, "c.layered-1");
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert!(matches!(doc.layers[0].content, LayerContent::Smart(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reply_flags_a_smart_object_so_a_queued_document_keeps_its_history_label() {
|
||||
let dir = tmp("flag");
|
||||
let path = write_png(&dir, "a_test.png", 16, 12);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["smartObject"], true, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_psd_is_held_for_confirmation_when_asked_and_opens_otherwise() {
|
||||
let dir = tmp("damaged");
|
||||
let path = join(&dir, "d.psd");
|
||||
std::fs::write(&path, crate::jobs::psd_open_fixture::make_psd(true)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path, "confirmDamage": true})).unwrap();
|
||||
assert_eq!(r["damaged"], true, "{r}");
|
||||
assert_eq!(r["smartObject"], true, "{r}");
|
||||
assert!(r["held"].is_u64(), "{r}");
|
||||
assert!(s.documents().is_empty(), "held for the damage prompt, not admitted");
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert!(r["docId"].is_u64(), "a headless caller opens the recovered content: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() {
|
||||
let dir = tmp("held-name");
|
||||
let path = write_psd16(&dir, "a_test.psd", 20, 20);
|
||||
let mut s = Session::new();
|
||||
let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(first["name"], "a_test-1");
|
||||
assert_eq!(second["name"], "a_test-2", "the held document's name is taken: {second}");
|
||||
// Both held docs leave the session for the app's queue; admitting them one by one numbers each.
|
||||
let (a, _) = s.take_held_open(first["held"].as_u64().unwrap()).unwrap();
|
||||
let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap();
|
||||
admit_as_smart_object(&mut s, a);
|
||||
admit_as_smart_object(&mut s, b);
|
||||
assert_eq!(names(&s), ["a_test-1", "a_test-2"]);
|
||||
let mut t = Session::new();
|
||||
let (c, _) = {
|
||||
let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
t.take_held_open(r["held"].as_u64().unwrap()).unwrap()
|
||||
};
|
||||
let (d, _) = {
|
||||
t.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap();
|
||||
let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
t.take_held_open(r["held"].as_u64().unwrap()).unwrap()
|
||||
};
|
||||
assert_eq!(c.name, d.name, "taken out of the session, both are named a_test-1 when read");
|
||||
admit_as_smart_object(&mut t, c);
|
||||
admit_as_smart_object(&mut t, d);
|
||||
assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() {
|
||||
let dir = tmp("cancelled-hold");
|
||||
let path = write_psd16(&dir, "photo.psd", 20, 20);
|
||||
let mut s = Session::new();
|
||||
let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!((first["name"].as_str(), second["name"].as_str()), (Some("photo-1"), Some("photo-2")));
|
||||
// photo-1 is cancelled while photo-2 waits; photo-2 is admitted with nothing open.
|
||||
s.take_held_open(first["held"].as_u64().unwrap()).unwrap();
|
||||
let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap();
|
||||
admit_as_smart_object(&mut s, b);
|
||||
assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reply_names_the_document_as_admitted() {
|
||||
let dir = tmp("reply-name");
|
||||
let path = write_psd16(&dir, "photo.psd", 20, 20);
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap();
|
||||
let held = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(held["name"], "photo-1", "{held}");
|
||||
assert!(held["held"].is_u64(), "{held}");
|
||||
// The next open skips the prompt: photo-1 is held, so the number read was 2, but admission takes the first free one.
|
||||
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
|
||||
let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap();
|
||||
assert_eq!(names(&s), ["photo-1"], "{r}");
|
||||
assert_eq!(r["name"], "photo-1", "the reply carries the name the document was admitted under: {r}");
|
||||
}
|
||||
@@ -5,9 +5,12 @@ use photocraft_io::{ImportResult, raw::RawTuning};
|
||||
|
||||
use super::{EngineError, Result, Session, Value, json};
|
||||
|
||||
/// The prior settings of a sidecar, and its crop (in the raw's stored orientation).
|
||||
type Prior = (CameraRaw, Option<photocraft_io::camera_raw_xmp::Crop>);
|
||||
|
||||
/// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
|
||||
fn prior_settings(path: Option<&str>) -> Result<Option<Prior>> {
|
||||
use std::io::Read;
|
||||
let Some(path) = path else { return Ok(None) };
|
||||
let sidecar = match path.rsplit_once('.') {
|
||||
@@ -31,11 +34,11 @@ fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
|
||||
if !report.ignored.is_empty() {
|
||||
return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", "))));
|
||||
}
|
||||
Ok(Some(settings))
|
||||
Ok(Some((settings, report.crop)))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn prior_settings(path: Option<&str>) -> Result<Option<CameraRaw>> {
|
||||
fn prior_settings(path: Option<&str>) -> Result<Option<Prior>> {
|
||||
if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) }
|
||||
}
|
||||
|
||||
@@ -67,6 +70,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8])
|
||||
return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into()));
|
||||
}
|
||||
let prior = prior_settings(path)?;
|
||||
let crop = prior.as_ref().and_then(|(_, crop)| *crop);
|
||||
let prior = prior.map(|(settings, _)| settings);
|
||||
let mut settings = prior.clone().unwrap_or_default();
|
||||
let opts = crate::jobs::import_options(s.prefs());
|
||||
let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?;
|
||||
@@ -87,6 +92,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8])
|
||||
clarity: settings.clarity,
|
||||
texture: settings.texture,
|
||||
dehaze: settings.dehaze,
|
||||
// The sidecar's crop, as the interactive open applies it: before the Camera Raw step.
|
||||
crop,
|
||||
};
|
||||
if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) {
|
||||
super::open::raw_defaults(s, &imported.warnings, &mut imported.document);
|
||||
|
||||
@@ -734,6 +734,27 @@ fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs()
|
||||
assert!(s.batch_open_notices.is_none());
|
||||
}
|
||||
|
||||
/// #9985: quiet RAW opening crops to the sidecar's crop, as the interactive open does.
|
||||
#[test]
|
||||
fn batch_quiet_raw_applies_the_sidecar_crop() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
let dir = tmp("batch-quiet-raw-crop");
|
||||
let path = join(&dir, "raw.dng");
|
||||
let (w, h) = (24, 16);
|
||||
let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 };
|
||||
let xmp = photocraft_io::camera_raw_xmp::save_with_crop(&photocraft_algo::camera_raw::CameraRaw::default(), Some(crop));
|
||||
std::fs::write(join(&dir, "raw.xmp"), &xmp).unwrap();
|
||||
let mut s = Session::new();
|
||||
let output = join(&dir, "out");
|
||||
let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]});
|
||||
let result = s.execute("file.automate.batch", p).unwrap();
|
||||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||||
let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document;
|
||||
assert_eq!((actual.size.width, actual.size.height), (12, 8), "the 24x16 frame cropped to x 3..15, y 4..12");
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
@@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
|
||||
/// Scale Effects, and previews that draw a style at a reduced resolution).
|
||||
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style ›
|
||||
/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %.
|
||||
pub fn scale_effects(fx: &mut Effects, k: f32) {
|
||||
scale_effects_with(fx, k, true);
|
||||
}
|
||||
|
||||
/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales
|
||||
/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a
|
||||
/// quarter of the size is 0.5 %, not the dialog's 1 % minimum).
|
||||
pub fn scale_effects_preview(fx: &mut Effects, k: f32) {
|
||||
scale_effects_with(fx, k, false);
|
||||
}
|
||||
|
||||
fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) {
|
||||
let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp);
|
||||
for e in &mut fx.items {
|
||||
match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
|
||||
@@ -152,14 +164,17 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
|
||||
Effect::BevelEmboss(b) => {
|
||||
b.size *= k;
|
||||
b.soften *= k;
|
||||
if let Some(t) = &mut b.texture {
|
||||
pattern(&mut t.scale);
|
||||
}
|
||||
}
|
||||
Effect::PatternOverlay { scale, .. } => *scale *= k,
|
||||
Effect::PatternOverlay { scale, .. } => pattern(scale),
|
||||
Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {}
|
||||
}
|
||||
if let Effect::Stroke(s) = e
|
||||
&& let FxPaint::Pattern { scale, .. } = &mut s.paint
|
||||
{
|
||||
*scale *= k;
|
||||
pattern(scale);
|
||||
}
|
||||
}
|
||||
// Effects changed: the preserved PSD block no longer applies.
|
||||
@@ -168,6 +183,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog
|
||||
/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %).
|
||||
fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) {
|
||||
let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE;
|
||||
let v = *scale * k;
|
||||
if !v.is_nan() {
|
||||
*scale = if clamp { v.clamp(min, max) } else { v };
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Size's resampling methods; `None` changes the resolution only.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Method {
|
||||
@@ -755,6 +780,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
|
||||
let unlock = !delete && !content_aware && !keep_background;
|
||||
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
|
||||
// the numbers passed; reject them (see `commands::int_i32`).
|
||||
let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()));
|
||||
let explicit = match (
|
||||
crate::commands::int_i32("image.crop", p, "x")?,
|
||||
crate::commands::int_i32("image.crop", p, "y")?,
|
||||
@@ -775,6 +801,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
|
||||
};
|
||||
Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?))
|
||||
}
|
||||
// A rectangle that is passed but incomplete or empty is refused: falling back to the
|
||||
// selection would crop somewhere the caller didn't ask for (#9279).
|
||||
_ if given => {
|
||||
return Err(bad(
|
||||
"image.crop",
|
||||
"`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1",
|
||||
));
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let target = crop_target(p)?;
|
||||
@@ -1206,7 +1240,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,"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; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
|
||||
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} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; 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; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
|
||||
has_doc,
|
||||
crop
|
||||
),
|
||||
@@ -1222,7 +1256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.mode.rgb",
|
||||
"RGB Color",
|
||||
["Image", "Mode"],
|
||||
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
|
||||
r##"{"profile":"<builtin id>|working|/path/to/profile.icc"=working (Indexed→RGB preserves the document profile unless explicit),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##,
|
||||
has_doc,
|
||||
|s, p| convert_mode(s, ColorMode::Rgb, p)
|
||||
),
|
||||
|
||||
@@ -429,6 +429,101 @@ fn crop_to_explicit_rect_can_extend_canvas() {
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279).
|
||||
#[test]
|
||||
fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() {
|
||||
let bad = [
|
||||
json!({"x": 10, "y": 10, "width": 0, "height": 50}),
|
||||
json!({"x": 10, "y": 10, "width": 20, "height": 0}),
|
||||
json!({"x": 10, "y": 10, "width": -50, "height": 50}),
|
||||
json!({"x": 10, "y": 10, "width": "100", "height": 50}),
|
||||
json!({"x": 10, "y": 10, "width": true, "height": 50}),
|
||||
json!({"width": 20, "height": 10}),
|
||||
json!({"x": 1, "y": 1}),
|
||||
json!({"x": 1, "y": null, "width": 20, "height": 10}),
|
||||
json!({"height": 10}),
|
||||
];
|
||||
for p in bad {
|
||||
for selection in [true, false] {
|
||||
let mut s = session();
|
||||
if selection {
|
||||
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
|
||||
}
|
||||
let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len());
|
||||
let err = s.execute("image.crop", p.clone()).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}");
|
||||
assert_eq!(doc(&s).size, size, "{p} (selection: {selection})");
|
||||
assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})");
|
||||
}
|
||||
}
|
||||
// No rectangle at all (or every side null) still crops to the selection.
|
||||
for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("image.crop", p.clone()).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(20, 10), "{p}");
|
||||
}
|
||||
// Whole-number floats, as the Crop tool sends them, are a valid rectangle.
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(30, 15));
|
||||
// Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param
|
||||
// (`commands::int`); a side that rounds to 0 is refused.
|
||||
let mut s = session();
|
||||
s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(21, 10));
|
||||
let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
assert_eq!(doc(&s).size, Size::new(21, 10));
|
||||
}
|
||||
|
||||
/// The explicit rectangle of #9279's edge cases, each with a selection elsewhere that a fallback
|
||||
/// would crop to: `0` is a given origin, numeric strings are not numbers (refused, as `x`/`y`
|
||||
/// already were), and a rectangle partly past the canvas is used as passed, never clamped.
|
||||
#[test]
|
||||
fn crop_rect_edge_cases_never_fall_back_to_the_selection() {
|
||||
let with_selection = || {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
|
||||
s
|
||||
};
|
||||
// `x`/`y` of 0 count as given: the red square (10, 5)..(20, 15) stays where it was, where a
|
||||
// crop to the selection would have moved it to (5, 3).
|
||||
let mut s = with_selection();
|
||||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 15, "height": 10})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(15, 10));
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 15, 10));
|
||||
// Numeric strings are refused on every side, with or without a selection.
|
||||
for p in [
|
||||
json!({"x": "0", "y": "0", "width": "15", "height": "10"}),
|
||||
json!({"x": "0", "y": 0, "width": 15, "height": 10}),
|
||||
json!({"x": 0, "y": 0, "width": 15, "height": "10"}),
|
||||
] {
|
||||
for mut s in [with_selection(), session()] {
|
||||
let err = s.execute("image.crop", p.clone()).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err:?}");
|
||||
assert_eq!(doc(&s).size, Size::new(40, 20), "{p}");
|
||||
}
|
||||
}
|
||||
// Partly outside the canvas: the canvas grows to the rectangle, not to its overlap with the
|
||||
// canvas or the selection.
|
||||
let mut s = with_selection();
|
||||
s.execute("image.crop", json!({"x": -10, "y": -5, "width": 30, "height": 40})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(30, 40));
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
// Reveal All passes all four sides through the same check (here x = y = 0: the hidden pixels
|
||||
// are past the right and bottom edges), so a selection doesn't change what it reveals.
|
||||
let mut s = with_selection();
|
||||
s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap();
|
||||
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true);
|
||||
let size = doc(&s).size;
|
||||
assert!(size.width >= 20 && size.height >= 15, "{size:?}: the red square is revealed");
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(19, 14), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_extension_fills_the_background_with_the_background_colour() {
|
||||
let mut shrink = Session::new();
|
||||
|
||||
@@ -140,6 +140,119 @@ fn global_light_blending_options_scale_effects() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() {
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, disc);
|
||||
s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap();
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap();
|
||||
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
|
||||
let fx = &active(&s).effects.items;
|
||||
let overlay = fx.iter().find_map(|e| match e {
|
||||
Effect::PatternOverlay { scale, .. } => Some(*scale),
|
||||
_ => None,
|
||||
});
|
||||
let texture = fx.iter().find_map(|e| match e {
|
||||
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
|
||||
_ => None,
|
||||
});
|
||||
// Both patterns tile at half size, so a texture aligned with the overlay stays aligned.
|
||||
assert_eq!((overlay, texture), (Some(0.5), Some(0.5)));
|
||||
}
|
||||
|
||||
/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in
|
||||
/// that order.
|
||||
fn pattern_scales(s: &Session) -> (Option<f32>, Option<f32>, Option<f32>) {
|
||||
let fx = &active(s).effects.items;
|
||||
let overlay = fx.iter().find_map(|e| match e {
|
||||
Effect::PatternOverlay { scale, .. } => Some(*scale),
|
||||
_ => None,
|
||||
});
|
||||
let texture = fx.iter().find_map(|e| match e {
|
||||
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
|
||||
_ => None,
|
||||
});
|
||||
let stroke = fx.iter().find_map(|e| match e {
|
||||
Effect::Stroke(st) => match &st.paint {
|
||||
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
(overlay, texture, stroke)
|
||||
}
|
||||
|
||||
/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %.
|
||||
fn pattern_styled(pct: f64) -> Session {
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, disc);
|
||||
s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap();
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap();
|
||||
s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap();
|
||||
s.edit("pattern stroke", |doc, active| {
|
||||
let layer = doc.layer_mut(active.unwrap()).unwrap();
|
||||
for e in &mut layer.effects.items {
|
||||
if let Effect::Stroke(st) = e {
|
||||
st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_effects_keeps_pattern_scales_within_the_dialog_range() {
|
||||
// 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %.
|
||||
let mut s = pattern_styled(10.0);
|
||||
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap();
|
||||
assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01)));
|
||||
// 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %.
|
||||
let mut s = pattern_styled(500.0);
|
||||
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap();
|
||||
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
|
||||
// Inside the range nothing is clamped: 200 % halved is 100 %.
|
||||
let mut s = pattern_styled(200.0);
|
||||
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
|
||||
assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0)));
|
||||
}
|
||||
|
||||
/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only
|
||||
/// commands that store the style clamp them.
|
||||
#[test]
|
||||
fn preview_scaling_leaves_pattern_scales_unclamped() {
|
||||
let s = pattern_styled(2.0);
|
||||
let mut fx = active(&s).effects.clone();
|
||||
crate::image_cmds::scale_effects_preview(&mut fx, 0.25);
|
||||
let mut clamped = active(&s).effects.clone();
|
||||
crate::image_cmds::scale_effects(&mut clamped, 0.25);
|
||||
let scales = |fx: &photocraft_doc::Effects| {
|
||||
fx.items
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::PatternOverlay { scale, .. } => Some(*scale),
|
||||
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
|
||||
Effect::Stroke(st) => match &st.paint {
|
||||
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(scales(&fx), vec![0.005; 3]);
|
||||
assert_eq!(scales(&clamped), vec![0.01; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() {
|
||||
// 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %.
|
||||
let mut s = pattern_styled(500.0);
|
||||
s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap();
|
||||
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_light_rejects_numbers_beyond_f32() {
|
||||
let mut s = session(8, "rgb");
|
||||
|
||||
@@ -101,7 +101,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers),
|
||||
spec!(
|
||||
"select.findLayers",
|
||||
"Find Layers",
|
||||
&["Select"],
|
||||
Some("Cmd+Alt+Shift+F"),
|
||||
r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, selecting none of the matches; without `name` the panel's Name text is kept; replies with the matching layers)"##,
|
||||
has_doc,
|
||||
find_layers
|
||||
),
|
||||
spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)),
|
||||
spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")),
|
||||
spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")),
|
||||
|
||||
@@ -336,16 +336,27 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result<Value> {
|
||||
Ok(json!({"selected": n}))
|
||||
}
|
||||
|
||||
/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the
|
||||
/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only
|
||||
/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and
|
||||
/// ignoring case, as the panel matches: every layer for a blank name, which the panel doesn't filter
|
||||
/// by (none without a name).
|
||||
pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase();
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?),
|
||||
};
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other(format!("no layer name contains \"{needle}\"")));
|
||||
let needle = name.map(|n| n.trim().to_lowercase());
|
||||
let ids: Vec<u64> = match &needle {
|
||||
Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut filter = json!({"by": "name"});
|
||||
if let Some(n) = name {
|
||||
filter["name"] = json!(n);
|
||||
}
|
||||
let out = ids.iter().map(|l| l.0).collect::<Vec<_>>();
|
||||
set_selection(s, ids, None, None)?;
|
||||
Ok(json!({"selected": out}))
|
||||
Ok(json!({"selected": ids, "layerFilter": filter}))
|
||||
}
|
||||
|
||||
pub(super) fn select_linked(s: &mut Session) -> Result<Value> {
|
||||
|
||||
@@ -586,9 +586,23 @@ fn find_layers_by_name() {
|
||||
s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap();
|
||||
s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap();
|
||||
assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo");
|
||||
let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap();
|
||||
assert_eq!(r["selected"], json!([a.0, b.0]));
|
||||
assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err());
|
||||
// #10884: Find Layers filters the Layers panel by Name and leaves the selection alone.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap();
|
||||
assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case");
|
||||
assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given");
|
||||
assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged");
|
||||
let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap();
|
||||
assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error");
|
||||
let all: Vec<u64> = doc(&s).walk().into_iter().map(|(_, _, l)| l.id.0).collect();
|
||||
for blank in ["", " "] {
|
||||
let r = s.execute("select.findLayers", json!({"name": blank})).unwrap();
|
||||
assert_eq!(r["selected"], json!(all), "a blank name filters nothing, as in the panel: {blank:?}");
|
||||
}
|
||||
let r = s.execute("select.findLayers", json!({})).unwrap();
|
||||
assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name");
|
||||
assert_eq!(s.active().unwrap().selected_layers(), [a]);
|
||||
assert!(s.execute("select.findLayers", json!({"name": 3})).is_err());
|
||||
assert!(s.execute("layer.renameLayer", json!({})).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind};
|
||||
use photocraft_algo::transform::{Homography, Interp};
|
||||
use photocraft_cms::{ColorSpace, TransformOptions};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::CurvePoint;
|
||||
use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
|
||||
@@ -255,6 +256,56 @@ fn parse_dither(s: &str) -> Option<Dither> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a tool colour from the working RGB space into the samples used by the
|
||||
/// flattened document. RGB and Indexed documents keep their own document profile;
|
||||
/// grayscale output is a scalar sample replicated into the flattened RGB view.
|
||||
fn working_matte_color(s: &Session, doc: &Document, color: [f32; 4]) -> Result<[f32; 3]> {
|
||||
let destination = match crate::color_cmds::mode_space(doc.mode) {
|
||||
ColorSpace::Rgb | ColorSpace::Gray => crate::color_cmds::document_profile(doc),
|
||||
// CMYK and Lab flatten through the compositor's sRGB view on the pinned main path.
|
||||
_ => crate::color_cmds::composite_profile(doc),
|
||||
};
|
||||
let source = s.color.working(ColorMode::Rgb);
|
||||
let options = TransformOptions { intent: s.color.settings.intent(), bpc: s.color.settings.bpc, ..Default::default() };
|
||||
let transform = photocraft_cms::transform::cached(&source, &destination, options)
|
||||
.map_err(|e| bad("image.mode.indexedColor", format!("cannot convert matte colour: {e}")))?;
|
||||
let mut output = [0.0; 16];
|
||||
transform.eval(&color[..3], &mut output);
|
||||
let matte = if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] };
|
||||
if matte.iter().any(|v| !v.is_finite()) {
|
||||
return Err(bad("image.mode.indexedColor", "cannot convert matte colour to finite document samples"));
|
||||
}
|
||||
Ok(matte.map(|v| v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
fn indexed_matte(s: &Session, p: &Value) -> Result<Option<[f32; 3]>> {
|
||||
const CMD: &str = "image.mode.indexedColor";
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let matte = match p.get("matte") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(value)) => match value.as_str() {
|
||||
"none" => return Ok(None),
|
||||
"white" => [1.0; 3],
|
||||
"black" => [0.0; 3],
|
||||
"gray" => [128.0 / 255.0; 3],
|
||||
"netscapeGray" => [192.0 / 255.0; 3],
|
||||
"foreground" => working_matte_color(s, doc, s.tools.foreground)?,
|
||||
"background" => working_matte_color(s, doc, s.tools.background)?,
|
||||
value if value.len() == 7 && value.starts_with('#') => p
|
||||
.get("matte")
|
||||
.and_then(parse_color)
|
||||
.ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb"))?
|
||||
.map(|v| f32::from(v) / 255.0),
|
||||
_ => return Err(bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb")),
|
||||
},
|
||||
_ => return Err(bad(CMD, "matte must be a colour string")),
|
||||
};
|
||||
if matte.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
|
||||
return Err(bad(CMD, "matte channels must be finite and in 0..=1"));
|
||||
}
|
||||
Ok(Some(matte))
|
||||
}
|
||||
|
||||
fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "image.mode.indexedColor";
|
||||
let kind = PaletteKind::from_id(str_or(p, "palette", "selective"))
|
||||
@@ -273,8 +324,23 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0;
|
||||
let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true);
|
||||
let matte = indexed_matte(s, p)?;
|
||||
let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| {
|
||||
let mut px = composite(doc, !transparency);
|
||||
let mut px = composite(doc, !transparency && matte.is_none());
|
||||
if let Some(matte) = matte {
|
||||
for pixel in &mut px {
|
||||
let alpha = pixel[3].clamp(0.0, 1.0);
|
||||
if alpha == 1.0 {
|
||||
continue;
|
||||
}
|
||||
if !transparency || alpha > 0.0 {
|
||||
for (channel, background) in pixel[..3].iter_mut().zip(matte) {
|
||||
*channel = *channel * alpha + background * (1.0 - alpha);
|
||||
}
|
||||
pixel[3] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let has_clear = transparency && px.iter().any(|q| q[3] < 0.5);
|
||||
let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?;
|
||||
let transparent = if has_clear && pal.len() < 256 {
|
||||
@@ -689,7 +755,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.mode.indexedColor",
|
||||
"Indexed Color…",
|
||||
["Image", "Mode"],
|
||||
r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens)"##,
|
||||
r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##,
|
||||
|s| mode_is(
|
||||
s,
|
||||
&[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab],
|
||||
|
||||
@@ -247,6 +247,160 @@ fn indexed_color_palettes_and_dithers() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_exact_preserves_profile_and_samples_through_default_rgb() {
|
||||
for depth in [8, 16, 32] {
|
||||
for profile in ["adobe-rgb-compat", "display-p3", "prophoto-compat", "none"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": profile})).unwrap();
|
||||
paint(&mut s, |_, _| [100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0, 1.0]);
|
||||
let source_profile = doc(&s).icc_profile.clone();
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false})).unwrap();
|
||||
assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: Indexed profile");
|
||||
assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[100, 150, 200]]);
|
||||
let indexed_pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
|
||||
s.execute("image.mode.rgb", json!({})).unwrap();
|
||||
assert_eq!(doc(&s).mode, ColorMode::Rgb);
|
||||
assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: RGB profile");
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel);
|
||||
assert!(doc(&s).color_table.is_none());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s).mode, ColorMode::Indexed);
|
||||
assert_eq!(doc(&s).icc_profile, source_profile);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s).mode, ColorMode::Rgb);
|
||||
assert_eq!(doc(&s).icc_profile, source_profile);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("image.mode.rgb", json!({"profile": "srgb"})).unwrap();
|
||||
assert_eq!(doc(&s).icc_profile, Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()), "an explicit target still converts");
|
||||
if profile != "none" {
|
||||
assert_ne!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_matte_blends_edges_without_replacing_clear_pixels() {
|
||||
for depth in [8, 16, 32] {
|
||||
for (matte, color) in [
|
||||
("black", [0.0, 0.0, 0.0]),
|
||||
("white", [1.0, 1.0, 1.0]),
|
||||
("#0000ff", [0.0, 0.0, 1.0]),
|
||||
("foreground", [0.0, 0.0, 1.0]),
|
||||
("background", [0.0, 1.0, 0.0]),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 1, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#0000ff", "background": "#00ff00"})).unwrap();
|
||||
paint(&mut s, |x, _| match x {
|
||||
0 => [0.0, 1.0, 0.0, 1.0],
|
||||
1 => [1.0, 0.0, 0.0, 0.75],
|
||||
2 => [1.0, 0.0, 0.0, 0.25],
|
||||
_ => [0.0; 4],
|
||||
});
|
||||
let source = [1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0));
|
||||
let expected = source.map(|pixel| [0, 1, 2].map(|i| pixel[i] * pixel[3] + color[i] * (1.0 - pixel[3])));
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap();
|
||||
let surface = doc(&s).layers[0].surface().unwrap();
|
||||
let edges = [1, 2].map(|x| surface.rgba(x, 0));
|
||||
for (edge, expected) in edges.iter().zip(expected) {
|
||||
for i in 0..3 {
|
||||
assert!((edge[i] - expected[i]).abs() <= 1.0 / 255.0, "{depth}-bit {matte}: {edge:?} vs {expected:?}");
|
||||
}
|
||||
assert_eq!(edge[3], 1.0);
|
||||
}
|
||||
assert_eq!(surface.rgba(3, 0)[3], 0.0, "{matte}: fully transparent pixels stay clear");
|
||||
assert_eq!(surface.rgba(0, 0), [0.0, 1.0, 0.0, 1.0]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), source);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), edges);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_matte_converts_working_rgb_to_document_profile_at_every_depth() {
|
||||
use photocraft_cms::Transform;
|
||||
|
||||
for depth in [8, 16, 32] {
|
||||
for (matte, color) in [("foreground", [0.0, 1.0, 0.0]), ("background", [1.0, 0.0, 0.0])] {
|
||||
for (intent, bpc) in [("relative", false), ("absolute", true)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "intent": intent, "bpc": bpc})).unwrap();
|
||||
s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#ff0000"})).unwrap();
|
||||
paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] });
|
||||
let pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
|
||||
let profile = doc(&s).icc_profile.clone();
|
||||
let source = s.color.working(ColorMode::Rgb);
|
||||
let destination = crate::color_cmds::document_profile(doc(&s));
|
||||
let transform = Transform::new(&source, &destination, s.color.settings.intent(), bpc).unwrap();
|
||||
let mut converted = [0.0; 16];
|
||||
transform.eval(&color, &mut converted);
|
||||
let expected = [0, 1, 2].map(|i| ((pixel[i] * pixel[3] + converted[i].clamp(0.0, 1.0) * (1.0 - pixel[3])) * 255.0).round() as u8);
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap();
|
||||
let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
|
||||
assert_eq!([edge[0], edge[1], edge[2]].map(|v| (v * 255.0).round() as u8), expected, "{depth}-bit {matte} {intent}, bpc={bpc}");
|
||||
assert_eq!(edge[3], 1.0);
|
||||
assert_eq!(doc(&s).icc_profile, profile);
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 0)[3], 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_matte_hex_stays_in_document_rgb_coordinates() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap();
|
||||
paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]);
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "#3366cc"})).unwrap();
|
||||
let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
|
||||
let expected = [0.5 + 0.5 * 0.2, 0.5 * 0.4, 0.5 * 0.8];
|
||||
for (got, want) in edge[..3].iter().zip(expected) {
|
||||
assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}");
|
||||
}
|
||||
assert_eq!(edge[3], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_matte_named_gray_choices_use_their_document_samples() {
|
||||
for (matte, channel) in [("gray", 128.0 / 255.0), ("netscapeGray", 192.0 / 255.0)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1, "height": 1, "depth": 32, "background": "transparent"})).unwrap();
|
||||
paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]);
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap();
|
||||
let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0);
|
||||
assert!((edge[0] - (0.5 + channel * 0.5)).abs() <= 1.0 / 255.0, "{matte}: {edge:?}");
|
||||
for value in edge[1..3].iter() {
|
||||
assert!((*value - channel * 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}");
|
||||
}
|
||||
assert_eq!(edge[3], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2, "height": 1, "background": "transparent"})).unwrap();
|
||||
let before = s.active().unwrap().revision;
|
||||
for matte in [json!(false), json!(12), json!("#gg0000"), json!("#000000ff"), json!("unknown")] {
|
||||
assert!(s.execute("image.mode.indexedColor", json!({"matte": matte})).is_err(), "{matte}");
|
||||
assert_eq!(s.active().unwrap().revision, before);
|
||||
assert_eq!(doc(&s).mode, ColorMode::Rgb);
|
||||
}
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false, "matte": "#0000ff"})).unwrap();
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_exact_transparency_and_sources() {
|
||||
// Two colours + transparency: exact palette with a transparent entry.
|
||||
|
||||
@@ -22,14 +22,33 @@ fn has_notes(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| !d.doc.notes.is_empty()) { Ok(()) } else { Err("the document has no notes".into()) }
|
||||
}
|
||||
|
||||
/// PDF-style date (`D:YYYYMMDDhhmmssZ`) as Photoshop stores in notes; empty without a clock.
|
||||
/// Photoshop's default note colour (#11795): RGB 255, 255, 131 (stored as 16-bit 0x8383 blue).
|
||||
pub const DEFAULT_NOTE_COLOR: [f32; 3] = [1.0, 1.0, 131.0 / 255.0];
|
||||
|
||||
/// The PDF date (`D:YYYYMMDDHHMMSS+HH'mm'`) Photoshop stores in a note: local time with its
|
||||
/// offset from UTC (#11795), through chrono. Empty without a clock (the web build).
|
||||
fn pdf_date() -> String {
|
||||
let iso = crate::analysis_cmds::now_iso();
|
||||
if iso.len() < 19 {
|
||||
return String::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let now = chrono::Local::now();
|
||||
pdf_date_at(&now.with_timezone(now.offset()))
|
||||
}
|
||||
let digits: String = iso[..19].chars().filter(char::is_ascii_digit).collect();
|
||||
format!("D:{digits}Z")
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`pdf_date`] for the moment `at`: its own wall-clock time and UTC offset.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn pdf_date_at<Tz: chrono::TimeZone>(at: &chrono::DateTime<Tz>) -> String
|
||||
where
|
||||
Tz::Offset: std::fmt::Display,
|
||||
{
|
||||
use chrono::Offset;
|
||||
let off = at.offset().fix().local_minus_utc();
|
||||
let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) };
|
||||
format!("D:{}{sign}{:02}'{:02}'", at.format("%Y%m%d%H%M%S"), off / 3600, off % 3600 / 60)
|
||||
}
|
||||
|
||||
pub fn note_json(i: usize, n: &Note) -> Value {
|
||||
@@ -44,7 +63,8 @@ fn color_of(cmd: &str, p: &Value) -> Result<Option<Color>> {
|
||||
if p.get("color").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?;
|
||||
let [r, g, b] = DEFAULT_NOTE_COLOR;
|
||||
let c = crate::commands::color_param(cmd, p, "color", [r, g, b, 1.0])?;
|
||||
Ok(Some(Color::rgb(c[0], c[1], c[2])))
|
||||
}
|
||||
|
||||
@@ -65,10 +85,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
author: p.get("author").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
text: p.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
position: [x, y],
|
||||
popup: [x + 20.0, y + 20.0, x + 260.0, y + 160.0],
|
||||
// Photoshop's popup: 240 × 140, at the icon's top-left + (8, 10) (#11795).
|
||||
popup: [x + 8.0, y + 10.0, x + 248.0, y + 150.0],
|
||||
color: Color::rgb(DEFAULT_NOTE_COLOR[0], DEFAULT_NOTE_COLOR[1], DEFAULT_NOTE_COLOR[2]),
|
||||
open: p.get("open").and_then(Value::as_bool).unwrap_or(true),
|
||||
modified: pdf_date(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(c) = color_of(CMD, p)? {
|
||||
n.color = c;
|
||||
@@ -270,7 +291,8 @@ mod tests {
|
||||
let d = s.active().unwrap().doc.clone();
|
||||
assert_eq!(d.notes[0].text, "fixed");
|
||||
assert_eq!(d.notes[0].position, [20.0, 12.0]);
|
||||
assert_eq!(d.notes[0].popup[0], 40.0);
|
||||
// The popup travels with its icon: 20 + 8 (#11795).
|
||||
assert_eq!(d.notes[0].popup[0], 28.0);
|
||||
// Unchanged edits don't add history.
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
assert_eq!(s.execute("notes.set", json!({"index": 0, "text": "fixed"})).unwrap()["changed"], false);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! #11795: what a new note stores, as in Photoshop: colour 255, 255, 131, a 240 × 140 popup at the
|
||||
//! icon's top-left + (8, 10), and a local-time PDF date with its offset. Document operations move
|
||||
//! the popup with its icon and keep its size.
|
||||
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
use serde_json::json;
|
||||
|
||||
use super::notes_cmds::{DEFAULT_NOTE_COLOR, pdf_date_at};
|
||||
use crate::Session;
|
||||
|
||||
fn session(w: u32, h: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn note(s: &Session, i: usize) -> photocraft_doc::Note {
|
||||
s.active().unwrap().doc.notes[i].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_note_has_the_default_colour_and_popup() {
|
||||
let mut s = session(600, 400);
|
||||
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
|
||||
let n = note(&s, 0);
|
||||
let rgb = n.color.to_rgb();
|
||||
assert_eq!(rgb.map(|v| (v * 255.0).round() as i32), [255, 255, 131], "{rgb:?}");
|
||||
assert_eq!(DEFAULT_NOTE_COLOR.map(|v| (v * 65535.0).round() as u32), [0xffff, 0xffff, 0x8383], "the 16-bit components Photoshop saves");
|
||||
assert_eq!(n.popup, [108.0, 110.0, 348.0, 250.0], "icon + (8, 10), 240 × 140");
|
||||
// A note on the pasteboard keeps its popup there too.
|
||||
s.execute("notes.add", json!({"x": -178, "y": 300})).unwrap();
|
||||
assert_eq!(note(&s, 1).popup, [-170.0, 310.0, 70.0, 450.0]);
|
||||
// An explicit colour still wins.
|
||||
s.execute("notes.add", json!({"x": 10, "y": 10, "color": [0.25, 0.5, 1.0]})).unwrap();
|
||||
assert_eq!(note(&s, 2).color.to_rgb(), [0.25, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_date_is_local_time_with_its_offset() {
|
||||
let at = |offset: i32| FixedOffset::east_opt(offset).unwrap().with_ymd_and_hms(2026, 11, 6, 20, 14, 55).unwrap();
|
||||
assert_eq!(pdf_date_at(&at(3600)), "D:20261106201455+01'00'");
|
||||
assert_eq!(pdf_date_at(&at(0)), "D:20261106201455+00'00'");
|
||||
assert_eq!(pdf_date_at(&at(-(5 * 3600 + 30 * 60))), "D:20261106201455-05'30'");
|
||||
// A new note's own date has that shape.
|
||||
let mut s = session(100, 100);
|
||||
s.execute("notes.add", json!({"x": 10, "y": 10})).unwrap();
|
||||
let d = note(&s, 0).modified;
|
||||
let b = d.as_bytes();
|
||||
assert!(d.len() == 23 && d.starts_with("D:") && matches!(b[16], b'+' | b'-') && b[19] == b'\'' && b[22] == b'\'', "{d}");
|
||||
assert!(d[2..16].chars().all(|c| c.is_ascii_digit()), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_moves_the_icon_and_keeps_the_popup_size() {
|
||||
let mut s = session(600, 400);
|
||||
s.execute("notes.add", json!({"x": 100, "y": 99})).unwrap();
|
||||
let before = note(&s, 0);
|
||||
s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap();
|
||||
let n = note(&s, 0);
|
||||
assert_eq!(n.position, [50.0, 49.5], "the icon point scales as today");
|
||||
let size = |p: [f64; 4]| [p[2] - p[0], p[3] - p[1]];
|
||||
assert_eq!(size(n.popup), size(before.popup), "240 × 140 still");
|
||||
assert_eq!([n.popup[0] - n.position[0], n.popup[1] - n.position[1]], [8.0, 10.0], "still at icon + (8, 10)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_size_and_a_quarter_turn_keep_the_popup_size() {
|
||||
let mut s = session(600, 400);
|
||||
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
|
||||
s.execute("image.canvasSize", json!({"width": 400, "height": 300, "anchor": "center"})).unwrap();
|
||||
let n = note(&s, 0);
|
||||
assert_eq!(n.position, [0.0, 50.0], "moved by the canvas offset");
|
||||
assert_eq!(n.popup, [8.0, 60.0, 248.0, 200.0]);
|
||||
s.execute("image.imageRotation.90cw", json!({})).unwrap();
|
||||
let r = note(&s, 0);
|
||||
assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_change_leaves_notes_alone() {
|
||||
let mut s = session(100, 100);
|
||||
s.execute("notes.add", json!({"x": 10, "y": 20})).unwrap();
|
||||
let before = note(&s, 0);
|
||||
s.execute("image.mode.cmyk", json!({})).unwrap();
|
||||
assert_eq!(note(&s, 0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_keeps_todays_popup_origin_and_the_popup_size() {
|
||||
// Where the popup sits after a turn wasn't measured (#11795), so it keeps today's origin (the
|
||||
// top-left of the turned rectangle) and only the size rule applies.
|
||||
let mut s = session(600, 400);
|
||||
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
|
||||
// The popup [108, 110, 348, 250] turned 90° clockwise on a 400 px tall canvas spans x 150..290,
|
||||
// y 108..348; it starts there and stays 240 × 140.
|
||||
s.execute("image.imageRotation.90cw", json!({})).unwrap();
|
||||
assert_eq!(note(&s, 0).popup, [150.0, 108.0, 390.0, 248.0]);
|
||||
// A flip keeps today's mirrored popup, which already has its size.
|
||||
let mut s = session(600, 400);
|
||||
s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap();
|
||||
s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap();
|
||||
assert_eq!(note(&s, 0).popup, [252.0, 110.0, 492.0, 250.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_popup_stays_unset_under_image_size() {
|
||||
// An all-zero popup means "none stored": PSD export writes its default popup for it, so Image
|
||||
// Size must not turn it into a 0 × 0 rectangle somewhere else.
|
||||
let mut s = session(600, 400);
|
||||
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
doc.notes.push(photocraft_doc::Note { position: [100.0, 100.0], ..Default::default() });
|
||||
s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap();
|
||||
let n = note(&s, 0);
|
||||
assert_eq!(n.position, [50.0, 50.0]);
|
||||
assert_eq!(n.popup, [0.0; 4]);
|
||||
}
|
||||
@@ -176,6 +176,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
|
||||
&& let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first)
|
||||
{
|
||||
let mut b = pr.brush.clone().picked_over(&s.tools.brush);
|
||||
// Opacity takes 1-100 %, as `tools.setBrush` floors it: a preset saved at 0 % would show
|
||||
// 1 % but paint nothing (#11812).
|
||||
b.opacity = b.opacity.max(0.01);
|
||||
s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?;
|
||||
s.tools.brush = b;
|
||||
s.tools.brush_changed();
|
||||
@@ -477,6 +480,24 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// #11812: selecting an imported brush whose tool options hold 0 % opacity floors it at 1 %,
|
||||
/// the value its options-bar field shows, so it paints instead of laying down nothing.
|
||||
#[test]
|
||||
fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() {
|
||||
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D};
|
||||
let preset = |opacity: i32| {
|
||||
Descriptor::new("brushPreset")
|
||||
.with("Nm ", D::Text(UnicodeString::new_nul("Faint")))
|
||||
.with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity))))
|
||||
};
|
||||
for (opacity, expected) in [(0, 0.01), (37, 0.37)] {
|
||||
let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap();
|
||||
assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_a_group_and_paints() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -260,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"#
|
||||
r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: healing_brush,
|
||||
@@ -397,3 +397,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod patch_history_names_tests;
|
||||
@@ -130,26 +130,19 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from_live = live.is_some();
|
||||
let map = clone_mapping(s, p, &stroke, CMD)?;
|
||||
let off = map.offset();
|
||||
let dmg =
|
||||
run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
|
||||
if let Some(l) = live {
|
||||
*surf = l.surface;
|
||||
return Ok(l.bounds);
|
||||
}
|
||||
if flag(p, "wrap", false) {
|
||||
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
|
||||
renderer.push(&stroke.points);
|
||||
renderer.finish();
|
||||
let before = surf.clone();
|
||||
let bounds = renderer.bounds();
|
||||
return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode }
|
||||
.draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD));
|
||||
}
|
||||
let (bounds, cov) = coverage(CMD, &stroke)?;
|
||||
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
|
||||
let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?;
|
||||
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
|
||||
})?;
|
||||
let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
|
||||
if let Some(l) = live {
|
||||
*surf = l.surface;
|
||||
return Ok(l.bounds);
|
||||
}
|
||||
if flag(p, "wrap", false) {
|
||||
return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD));
|
||||
}
|
||||
let (bounds, cov) = coverage(CMD, &stroke)?;
|
||||
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
|
||||
let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?;
|
||||
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
|
||||
})?;
|
||||
let mut r = clone_result(dmg, off, aligned, p, &stroke);
|
||||
if from_live {
|
||||
r["fromLive"] = json!(true);
|
||||
@@ -272,11 +265,14 @@ impl LiveRetouch {
|
||||
/// Everything the stroke has touched so far.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
let bounds = self.renderer.bounds().union(&self.tail);
|
||||
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
|
||||
periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b))
|
||||
} else {
|
||||
bounds
|
||||
}
|
||||
if self.wraps() { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds }
|
||||
}
|
||||
|
||||
/// Whether the stroke repeats across the canvas edges (View › Pattern Preview). Only the Clone
|
||||
/// Stamp and the Healing Brush do: the reference application's other retouching tools stop
|
||||
/// at the edge.
|
||||
fn wraps(&self) -> bool {
|
||||
matches!(self.cmd.as_str(), "paint.cloneStamp" | "paint.healingBrush") && flag(&self.params, "wrap", false)
|
||||
}
|
||||
|
||||
/// Every point rendered so far, in order.
|
||||
@@ -328,7 +324,7 @@ impl LiveRetouch {
|
||||
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
|
||||
self.points.extend_from_slice(&pts);
|
||||
self.renderer.push(&pts);
|
||||
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
|
||||
if self.wraps() {
|
||||
return self.push_periodic();
|
||||
}
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
@@ -360,6 +356,8 @@ impl LiveRetouch {
|
||||
let LivePaint::Clone { which, map, cmd } = &self.paint else {
|
||||
return Err(bad(&self.cmd, "periodic painting requires a clone source"));
|
||||
};
|
||||
let (pre, id, pre_surf, live_cmd) = (&self.pre, self.id, &self.pre_surf, self.cmd.as_str());
|
||||
let paint = |r| clone_sample(pre, id, pre_surf, *which, r, map, true, cmd);
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||||
periodic::Composite {
|
||||
target: surf,
|
||||
@@ -369,8 +367,9 @@ impl LiveRetouch {
|
||||
selection: self.sel.as_ref(),
|
||||
lock: self.lock,
|
||||
mode: self.mode,
|
||||
cmd: live_cmd,
|
||||
}
|
||||
.draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd))
|
||||
.draw(dirty, &self.brush, &self.points, paint)
|
||||
}
|
||||
|
||||
/// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::clone::{blend_param, clamp_samples, clone_mapping, clone_result, clone_sample};
|
||||
use super::periodic;
|
||||
use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers};
|
||||
use super::{
|
||||
BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad,
|
||||
@@ -53,15 +54,30 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let map = clone_mapping(s, p, &stroke, CMD)?;
|
||||
let off = map.offset();
|
||||
let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| {
|
||||
// Pattern Preview: source and destination repeat across the canvas edges, so a stroke over
|
||||
// a seam heals as one patch and goes on wherever it repeats.
|
||||
let wrap = flag(p, "wrap", false);
|
||||
let (bounds, cov) = coverage(CMD, &stroke)?;
|
||||
let (g, cov) = pad_coverage(bounds, &cov, 2);
|
||||
let fmt = surf.format();
|
||||
let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?;
|
||||
let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?;
|
||||
// The destination the texture is fitted to: the layer itself, or what the user sees when
|
||||
// sampling several layers (so healing onto an empty layer matches the composite).
|
||||
let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) };
|
||||
let dst = if wrap {
|
||||
periodic::sample(pre, id, surf, which, g, CMD)?
|
||||
} else if which == SampleLayers::Current {
|
||||
Region::read(surf, g)
|
||||
} else {
|
||||
composite_region(pre, id, which, g, fmt)
|
||||
};
|
||||
let mask: Vec<bool> = cov.iter().map(|c| *c > 0.0).collect();
|
||||
let healed = heal_region(&fmt, &src, &dst, &mask);
|
||||
if wrap {
|
||||
return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| {
|
||||
let part = healed.crop(r);
|
||||
if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) }
|
||||
});
|
||||
}
|
||||
Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode))
|
||||
})?;
|
||||
Ok(clone_result(dmg, off, aligned, p, &stroke))
|
||||
|
||||
@@ -23,6 +23,8 @@ use super::{
|
||||
};
|
||||
|
||||
const CMD: &str = "paint.patch";
|
||||
/// The History step of applying a patch, in every mode.
|
||||
const HISTORY_NAME: &str = "Patch Selection";
|
||||
|
||||
/// Largest patch, in pixels of the selection's bounding box. The solve holds the two regions, the
|
||||
/// result and per-channel float planes of it (on the order of 150 bytes per pixel with four
|
||||
@@ -151,6 +153,7 @@ struct Plan {
|
||||
/// Shift of the repaired area from the selection, and of the texture from the repaired area.
|
||||
dst_shift: (i32, i32),
|
||||
src_shift: (i32, i32),
|
||||
/// Destination mode: a hard rectangular selection moves with the patch.
|
||||
destination: bool,
|
||||
offset: (i32, i32),
|
||||
}
|
||||
@@ -211,16 +214,63 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste
|
||||
apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal)
|
||||
}
|
||||
|
||||
/// Rows scanned per band by [`is_hard_rect_within`], so the coverage buffer stays small.
|
||||
const RECT_BAND_ROWS: i32 = 64;
|
||||
|
||||
/// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel
|
||||
/// inside fully selected (the only Destination selection measured to move with the patch). The
|
||||
/// bounds may reach past the canvas; the patched area is then only the part on the canvas. Bounds
|
||||
/// of more than [`MAX_PIXELS`] pixels (the patch's own cap) are never classified as a hard
|
||||
/// rectangle, so a huge selection moved mostly off the canvas is not scanned and stays in place.
|
||||
fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool {
|
||||
is_hard_rect_within(sel, bounds, MAX_PIXELS)
|
||||
}
|
||||
|
||||
/// [`is_hard_rect`] with the pixel `limit` given. Scans `bounds` in bands of [`RECT_BAND_ROWS`] rows
|
||||
/// and stops at the first pixel that is not fully selected.
|
||||
fn is_hard_rect_within(sel: &Surface, bounds: Rect, limit: u64) -> bool {
|
||||
let (w, h) = ((i64::from(bounds.x1) - i64::from(bounds.x0)).max(0) as u64, (i64::from(bounds.y1) - i64::from(bounds.y0)).max(0) as u64);
|
||||
if w.saturating_mul(h) > limit || sel.default_pixel().first().copied().unwrap_or(0.0) > 0.0 {
|
||||
return false;
|
||||
}
|
||||
let mut y = bounds.y0;
|
||||
while y < bounds.y1 {
|
||||
let y1 = y.saturating_add(RECT_BAND_ROWS).min(bounds.y1);
|
||||
if !coverage(sel, Rect::new(bounds.x0, y, bounds.x1, y1)).iter().all(|c| *c >= 1.0) {
|
||||
return false;
|
||||
}
|
||||
y = y1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if flag(p, "contentAware", false) {
|
||||
return patch_content_aware(s, p);
|
||||
}
|
||||
let plan = plan(s, p)?;
|
||||
let label = if plan.destination { "Patch (Destination)" } else { "Patch" };
|
||||
let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| {
|
||||
let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
|
||||
Ok(patch_surface(surf, sel, &plan, lock, 1))
|
||||
})?;
|
||||
let label = HISTORY_NAME;
|
||||
let dmg = if plan.destination {
|
||||
// Destination: a plain rectangular selection moves with the patch, in the same history state.
|
||||
let dmg = s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
|
||||
let dmg = patch_surface(surf, &sel, &plan, lock, 1);
|
||||
let bounds = sel.content_bounds();
|
||||
if is_hard_rect(&sel, bounds) {
|
||||
let (dx, dy) = plan.offset;
|
||||
doc.selection = Some(sel.translated(dx, dy, bounds));
|
||||
}
|
||||
Ok(dmg)
|
||||
})?;
|
||||
s.hint_damage(dmg);
|
||||
dmg
|
||||
} else {
|
||||
run_stroke(s, label, plan.id, p, |pre, surf, _, lock| {
|
||||
let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?;
|
||||
Ok(patch_surface(surf, sel, &plan, lock, 1))
|
||||
})?
|
||||
};
|
||||
Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] }))
|
||||
}
|
||||
|
||||
@@ -263,6 +313,7 @@ fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result<u8> {
|
||||
/// docs), as a background job.
|
||||
fn patch_content_aware(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use super::content_aware_move::{Target, land, mask, read, window};
|
||||
// The job's progress text; its History step is `HISTORY_NAME`.
|
||||
const LABEL: &str = "Patch (Content-Aware)";
|
||||
let plan = plan(s, p)?;
|
||||
let structure = level(p, "structure", 4, 1, 7)?;
|
||||
@@ -294,7 +345,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
},
|
||||
move |s, (surf, damage)| {
|
||||
s.edit(LABEL, |doc, _| {
|
||||
s.edit(HISTORY_NAME, |doc, _| {
|
||||
*crate::channel_cmds::target_surface(doc, target.id, &p)?.0 = surf;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -303,3 +354,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "patch_destination_selection_tests.rs"]
|
||||
mod patch_destination_selection_tests;
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Patch in Destination mode moves a plain rectangular selection with the patch (#12119).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 800, "height": 600, "depth": 8, "mode": "rgb"})).unwrap();
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let v = if (x / 8 + y / 8) % 2 == 0 { 0.2 } else { 0.8 };
|
||||
let px = if x >= 400 { [0.1, 0.6, 0.2, 1.0] } else { [v, v, v, 1.0] };
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, px));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn bounds(s: &mut Session) -> Value {
|
||||
s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone()
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn history_len(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn select_rect(s: &mut Session) {
|
||||
s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap();
|
||||
}
|
||||
|
||||
const DESTINATION: fn() -> Value = || json!({"mode": "destination", "offset": [460, 280]});
|
||||
|
||||
#[test]
|
||||
fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() {
|
||||
let mut s = session();
|
||||
select_rect(&mut s);
|
||||
let before = px(&s, 600, 420);
|
||||
let source_before: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect();
|
||||
let r = s.execute("paint.patch", DESTINATION()).unwrap();
|
||||
assert_eq!(r["offset"], json!([460, 280]));
|
||||
// The damage covers the patched area (the 40 px square at the destination, plus the solve's ring).
|
||||
assert_eq!(r["damage"], json!([578, 398, 44, 44]));
|
||||
assert_eq!(bounds(&mut s), json!([580, 400, 40, 40]));
|
||||
assert_ne!(px(&s, 600, 420), before, "pixels changed too");
|
||||
// Source pixels untouched.
|
||||
let source_after: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect();
|
||||
assert_eq!(source_after, source_before);
|
||||
// The destination carries the source texture: the step between two neighbouring checker cells is kept
|
||||
// in the same direction and at a similar strength (the seamless clone adds a smooth colour correction, and the
|
||||
// red channel clamps at 0 on the dark green, so the step shrinks).
|
||||
let step_src = px(&s, 140, 140)[0] - px(&s, 148, 140)[0];
|
||||
let step_dst = px(&s, 600, 420)[0] - px(&s, 608, 420)[0];
|
||||
assert!(step_src * step_dst > 0.0 && step_dst.abs() > 0.2, "texture kept: source step {step_src}, destination step {step_dst}");
|
||||
// The moved selection is still a hard-edged rectangle exactly filling its bounds.
|
||||
let d = s.active().unwrap();
|
||||
let sel = d.doc.selection.as_ref().unwrap();
|
||||
assert!(is_hard_rect(sel, sel.content_bounds()));
|
||||
// One history state: a single undo brings back the pixels and the old selection.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
|
||||
assert_eq!(px(&s, 600, 420), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_selection_stays_when_it_is_not_a_plain_rectangle() {
|
||||
let cases: [(&str, Value, Value); 4] = [
|
||||
("feathered", json!({"x": 120, "y": 120, "width": 40, "height": 40, "feather": 6.0}), json!(null)),
|
||||
("ellipse", json!({"x": 120, "y": 120, "width": 40, "height": 40, "ellipse": true}), json!(null)),
|
||||
("lasso", json!({"points": [[120, 120], [160, 130], [140, 165]]}), json!("lasso")),
|
||||
("rectangle with a hole", json!({"x": 120, "y": 120, "width": 40, "height": 40}), json!("hole")),
|
||||
];
|
||||
for (name, params, kind) in cases {
|
||||
let mut s = session();
|
||||
let cmd = if kind == json!("lasso") { "select.lasso" } else { "select.rect" };
|
||||
s.execute(cmd, params).unwrap();
|
||||
if kind == json!("hole") {
|
||||
// Same bounds as the rectangle, but not every pixel inside is fully selected.
|
||||
s.execute("select.rect", json!({"x": 135, "y": 135, "width": 10, "height": 10, "mode": "subtract"})).unwrap();
|
||||
}
|
||||
let sel_before = bounds(&mut s);
|
||||
let area = |s: &Session| -> Vec<[f32; 4]> { (400..440).flat_map(|y| (580..620).map(move |x| (x, y))).map(|(x, y)| px(s, x, y)).collect() };
|
||||
let before = area(&s);
|
||||
s.execute("paint.patch", DESTINATION()).unwrap();
|
||||
assert_eq!(bounds(&mut s), sel_before, "{name}: the selection stays in place");
|
||||
assert_ne!(area(&s), before, "{name}: the patch still lands");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_coverage_and_oversized_rectangles_are_not_constructible_apart_from_the_hole_case() {
|
||||
// `plan.area` is the selection's own content bounds, so a rectangle larger than the area cannot
|
||||
// exist, and the area is never wider than the selection. The only way the bounds equal the area
|
||||
// yet coverage falls short is a hole (covered above). An inverted selection makes the area the
|
||||
// whole canvas, which no non-zero drag can keep on the canvas, so Patch refuses it.
|
||||
let mut s = session();
|
||||
select_rect(&mut s);
|
||||
s.execute("select.inverse", json!({})).unwrap();
|
||||
assert!(s.execute("paint.patch", DESTINATION()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_without_a_selection_fails_and_leaves_history_alone() {
|
||||
let mut s = session();
|
||||
// A marker step after the setup, so a stray undo would be visible.
|
||||
s.edit("marker", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let h = history_len(&s);
|
||||
assert!(s.execute("paint.patch", DESTINATION()).is_err());
|
||||
assert_eq!(history_len(&s), h, "no history entry");
|
||||
// Undo reverts the marker (the last real step), not the setup.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_ne!(px(&s, 15, 15), [1.0, 0.0, 0.0, 1.0], "the marker step was undone");
|
||||
assert!((px(&s, 450, 300)[0] - 0.1).abs() < 0.01 && (px(&s, 450, 300)[1] - 0.6).abs() < 0.01, "the setup step is still there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_restores_the_patched_pixels_and_the_moved_selection() {
|
||||
let mut s = session();
|
||||
select_rect(&mut s);
|
||||
let before = px(&s, 600, 420);
|
||||
s.execute("paint.patch", DESTINATION()).unwrap();
|
||||
let patched = px(&s, 600, 420);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
|
||||
assert_eq!(px(&s, 600, 420), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([580, 400, 40, 40]));
|
||||
assert_eq!(px(&s, 600, 420), patched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_mode_keeps_the_selection() {
|
||||
let mut s = session();
|
||||
select_rect(&mut s);
|
||||
s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_keeps_the_selection_and_changes_the_pixels() {
|
||||
let mut s = session();
|
||||
select_rect(&mut s);
|
||||
// Content-Aware ignores the mode and fills the selection itself from the dragged-to place (the
|
||||
// green half), so the pixels under the selection change.
|
||||
let before = px(&s, 140, 140);
|
||||
s.execute("paint.patch", json!({"offset": [400, 0], "contentAware": true})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([120, 120, 40, 40]));
|
||||
assert_ne!(px(&s, 140, 140), before, "the selected area was filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() {
|
||||
let mut s = session();
|
||||
// 60x40 rectangle with 20 columns off the left edge; the part on the canvas is x 0..40.
|
||||
s.execute("select.rect", json!({"x": 0, "y": 100, "width": 60, "height": 40})).unwrap();
|
||||
s.execute("select.transformSelection", json!({"dx": -20, "dy": 0})).unwrap();
|
||||
let sel_before = bounds(&mut s);
|
||||
let before = px(&s, 480, 400);
|
||||
s.execute("paint.patch", DESTINATION()).unwrap();
|
||||
assert_ne!(px(&s, 480, 400), before, "the patch lands on the canvas part");
|
||||
let d = s.active().unwrap();
|
||||
let sel = d.doc.selection.as_ref().unwrap();
|
||||
assert_eq!(sel.content_bounds(), Rect::new(440, 380, 500, 420), "the whole rectangle moved, off-canvas part included");
|
||||
assert!(sel.read_region(Rect::new(440, 380, 500, 420)).chunks_exact(sel.channels().max(1)).all(|p| p[0] >= 1.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(bounds(&mut s), sel_before);
|
||||
assert_eq!(px(&s, 480, 400), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_rect_check_is_capped_and_scans_every_band() {
|
||||
let mut s = session();
|
||||
// 40x150 rectangle: three bands of 64 rows (64 + 64 + 22).
|
||||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 150})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let sel = d.doc.selection.as_ref().unwrap();
|
||||
let b = sel.content_bounds();
|
||||
assert_eq!(b, Rect::new(100, 100, 140, 250));
|
||||
assert!(!is_hard_rect_within(sel, b, 5999), "over the limit is never a hard rectangle");
|
||||
assert!(is_hard_rect_within(sel, b, 6000), "at the limit it is checked as usual");
|
||||
assert!(is_hard_rect_within(sel, b, u64::MAX));
|
||||
// A hole in the third band must still be found.
|
||||
let mut holed = sel.clone();
|
||||
let fmt = holed.format();
|
||||
holed.write_region(Rect::new(120, 240, 121, 241), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 0.0]));
|
||||
assert!(!is_hard_rect_within(&holed, b, u64::MAX), "a hole past the first band fails the check");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! History step names of the Patch Tool (#12117): "Patch Tool" for the lasso it draws with and
|
||||
//! "Patch Selection" for applying a patch in every mode; the plain Lasso tool keeps "Lasso".
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 128, "height": 64, "depth": 8, "mode": "rgb"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> Option<String> {
|
||||
s.active().unwrap().history.undo_label().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_patch_is_one_step_named_patch_selection_in_every_mode() {
|
||||
let modes = [
|
||||
json!({"offset": [30, 0]}),
|
||||
json!({"offset": [30, 0], "mode": "source"}),
|
||||
json!({"offset": [30, 0], "mode": "destination"}),
|
||||
json!({"offset": [30, 0], "contentAware": true}),
|
||||
json!({"offset": [30, 0], "mode": "destination", "contentAware": true}),
|
||||
];
|
||||
for p in modes {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("paint.patch", p.clone()).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{p}: one step");
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Patch Selection"), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
const TRIANGLE: [[i32; 2]; 3] = [[16, 22], [36, 22], [16, 42]];
|
||||
|
||||
#[test]
|
||||
fn the_lasso_is_named_for_the_tool_that_drew_it() {
|
||||
let mut s = session();
|
||||
s.execute("select.lasso", json!({"points": TRIANGLE})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Lasso"), "the Lasso tool sends no name");
|
||||
for mode in ["replace", "add", "subtract", "intersect"] {
|
||||
s.execute("select.lasso", json!({"points": TRIANGLE, "mode": mode, "historyName": "Patch Tool"})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Patch Tool"), "{mode}");
|
||||
}
|
||||
s.execute("select.lasso", json!({"points": TRIANGLE, "polygonal": true, "historyName": "Patch Tool"})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Polygonal Lasso"), "the Polygonal Lasso keeps its own name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_history_name_is_refused_without_a_step() {
|
||||
let mut s = session();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let invisible = ["\u{200B}", " \u{200B}\u{FEFF} ", "\u{3164}", "\u{FE0F}", "\u{E0020}", "\u{00AD}", "\u{2800}"];
|
||||
let hidden = ["Patch\u{202E}looT", "\u{FEFF}Patch Tool", "Patch Tool\u{200B}", "\u{2066}Patch", "Patch\u{2028}Tool", "a\u{2029}b"];
|
||||
let ends = ["Patch Tool\n", "\r\nPatch Tool", "\tPatch", "\u{0085}Patch", "Patch\u{2028}"];
|
||||
let others = [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])];
|
||||
let others = ends.iter().map(|n| json!(n)).chain(others);
|
||||
for name in invisible.iter().chain(&hidden).map(|n| json!(n)).chain(others) {
|
||||
assert!(s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).is_err(), "{name}");
|
||||
let polygonal = json!({"points": TRIANGLE, "polygonal": true, "historyName": name});
|
||||
assert!(s.execute("select.lasso", polygonal).is_err(), "polygonal {name}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_joining_its_letters_with_a_zero_width_joiner_is_kept() {
|
||||
let mut s = session();
|
||||
for name in ["\u{0645}\u{200C}\u{06CC}", "\u{1F469}\u{200D}\u{1F4BB}", "Patch Tool \u{2713}"] {
|
||||
s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_and_joiners_at_the_ends_are_trimmed_and_only_what_is_left_counts() {
|
||||
let mut s = session();
|
||||
let padded = format!("{}{}", " ".repeat(10), "x".repeat(60));
|
||||
let joined = format!("Patch Tool{}", "\u{200D}".repeat(60));
|
||||
let cases = [
|
||||
("Patch Tool\u{200D}", "Patch Tool"),
|
||||
(" \u{200C}Patch Tool ", "Patch Tool"),
|
||||
("Love \u{2764}\u{FE0F}", "Love \u{2764}\u{FE0F}"),
|
||||
(joined.as_str(), "Patch Tool"),
|
||||
("Patch Tool\u{2800}", "Patch Tool"),
|
||||
("\u{3164}\u{2800}Patch Tool\u{FFA0} \u{115F}", "Patch Tool"),
|
||||
("Patch Tool\u{2065}\u{FFF0}", "Patch Tool"),
|
||||
("\u{17B4}Patch Tool", "Patch Tool"),
|
||||
("Patch Tool \u{FE0F}", "Patch Tool"),
|
||||
("\u{FE0F}Patch Tool", "Patch Tool"),
|
||||
("Patch \u{2764}\u{FE0F}\u{2800}", "Patch \u{2764}\u{FE0F}"),
|
||||
("Patch \u{1100}\u{1160}", "Patch \u{1100}\u{1160}"),
|
||||
("\u{115F}\u{1161} Patch", "\u{115F}\u{1161} Patch"),
|
||||
];
|
||||
for (name, stored) in cases.iter().copied().chain([(padded.as_str(), &padded[10..])]) {
|
||||
s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some(stored), "{name:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bounded reads from the repeating document tile used by Pattern Preview.
|
||||
|
||||
use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, StrokePoint, Surface, alpha_index, apply_coverage, bad};
|
||||
use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, Stroke, StrokePoint, Surface, alpha_index, apply_coverage, bad};
|
||||
|
||||
/// At most four disjoint rectangles in the document tile. Projecting the dirty area separately
|
||||
/// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam.
|
||||
@@ -23,6 +23,33 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec<Rect> {
|
||||
ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect()
|
||||
}
|
||||
|
||||
/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a
|
||||
/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and
|
||||
/// they go on through the stroke's coverage wherever it repeats on the document.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn stroke(
|
||||
pre: &Document,
|
||||
target: &mut Surface,
|
||||
stroke: &Stroke,
|
||||
selection: Option<&Surface>,
|
||||
lock: bool,
|
||||
mode: BlendMode,
|
||||
cmd: &str,
|
||||
mut paint: impl FnMut(&Surface, Rect) -> Result<Region>,
|
||||
) -> Result<Rect> {
|
||||
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
|
||||
renderer.push(&stroke.points);
|
||||
renderer.finish();
|
||||
let before = target.clone();
|
||||
let bounds = renderer.bounds();
|
||||
Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw(
|
||||
bounds,
|
||||
&stroke.brush,
|
||||
&stroke.points,
|
||||
|r| paint(&before, r),
|
||||
)
|
||||
}
|
||||
|
||||
/// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels.
|
||||
/// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and
|
||||
/// selection are applied once after all dabs, including repeated visits through other tiles.
|
||||
@@ -34,6 +61,8 @@ pub(super) struct Composite<'a> {
|
||||
pub selection: Option<&'a Surface>,
|
||||
pub lock: bool,
|
||||
pub mode: BlendMode,
|
||||
/// The command an over-budget stroke's error names.
|
||||
pub cmd: &'a str,
|
||||
}
|
||||
|
||||
impl Composite<'_> {
|
||||
@@ -138,7 +167,7 @@ impl Composite<'_> {
|
||||
);
|
||||
*work = work.saturating_add(r.width() as u64 * r.height() as u64);
|
||||
if *work > 64 * 1024 * 1024 {
|
||||
return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget"));
|
||||
return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget"));
|
||||
}
|
||||
ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths));
|
||||
let mut sampled = None;
|
||||
|
||||
@@ -1022,7 +1022,7 @@ fn patch_content_aware_copies_the_dragged_to_content_into_the_selection() {
|
||||
let (got, want) = (rgba(&s, x, y), texture(x, y));
|
||||
assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth}: untouched {x},{y}: {got:?}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch (Content-Aware)"));
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch Selection"));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(rgba(&s, 44, 24)[1], 0.0, "depth {depth}: undo brings the blemish back");
|
||||
}
|
||||
@@ -2532,3 +2532,192 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// View › Pattern Preview: Clone Stamp and Healing Brush wrap across the canvas edges (#8883)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The canvas the Pattern Preview tests wrap around.
|
||||
const WRAP: (u32, u32) = (48, 36);
|
||||
|
||||
/// `texture` repeated every canvas, so each copy of a pixel holds the same colour.
|
||||
fn tiled_texture(x: i32, y: i32) -> [f32; 4] {
|
||||
texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32))
|
||||
}
|
||||
|
||||
/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets
|
||||
/// the next copies, out across both of its edges.
|
||||
fn corner_stroke(extra: &Value) -> Value {
|
||||
let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The stroke tools that wrap, with their options. Both composite a paint image through the
|
||||
/// stroke's coverage (`periodic::Composite`), which matches the ordinary composite only within
|
||||
/// rounding.
|
||||
fn wrap_cases() -> [(&'static str, Value); 2] {
|
||||
[("paint.healingBrush", json!({"offset": [-20, -14]})), ("paint.cloneStamp", json!({"offset": [-20, -14]}))]
|
||||
}
|
||||
|
||||
/// The native-channel difference `periodic::Composite` may leave against the ordinary composite.
|
||||
fn composite_tolerance(depth: u64) -> f32 {
|
||||
match depth {
|
||||
8 => 1.1 / 255.0,
|
||||
16 => 1.1 / 65535.0,
|
||||
_ => 0.00001,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() {
|
||||
// A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the
|
||||
// copy of that pixel it goes over, on a document holding the canvas tiled three by three.
|
||||
let (w, h) = (WRAP.0 as usize, WRAP.1 as usize);
|
||||
for depth in DEPTHS {
|
||||
for (cmd, extra) in wrap_cases() {
|
||||
let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb");
|
||||
paint_layer(&mut canvas, tiled_texture);
|
||||
let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb");
|
||||
paint_layer(&mut tiled, tiled_texture);
|
||||
let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra));
|
||||
wrapped["wrap"] = json!(true);
|
||||
canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}"));
|
||||
let before = layer_pixels(&tiled);
|
||||
tiled.execute(cmd, plain).unwrap();
|
||||
let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled));
|
||||
let n = got.len() / (w * h);
|
||||
let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n;
|
||||
let tolerance = composite_tolerance(depth);
|
||||
let (mut moved, mut edges) = (0, [false; 4]);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h)));
|
||||
let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect();
|
||||
let (pixel, expected) = match changed.as_slice() {
|
||||
[] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]),
|
||||
[(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]),
|
||||
_ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"),
|
||||
};
|
||||
let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}");
|
||||
if !changed.is_empty() {
|
||||
moved += 1;
|
||||
edges[0] |= x < 8;
|
||||
edges[1] |= x >= w - 8;
|
||||
edges[2] |= y < 8;
|
||||
edges[3] |= y >= h - 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() {
|
||||
let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60});
|
||||
for depth in [8, 32] {
|
||||
for (cmd, extra) in wrap_cases() {
|
||||
let run = |wrap: bool| {
|
||||
let mut s = session(300, 230, depth, "rgb");
|
||||
paint_layer(&mut s, tiled_texture);
|
||||
let mut q = p.clone();
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
q["wrap"] = json!(wrap);
|
||||
s.execute(cmd, q).unwrap();
|
||||
layer_pixels(&s)
|
||||
};
|
||||
let (plain, wrapped) = (run(false), run(true));
|
||||
let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(error <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() {
|
||||
for (cmd, extra) in wrap_cases() {
|
||||
let mut s = session(16, 12, 8, "rgb");
|
||||
paint_layer(&mut s, tiled_texture);
|
||||
let before = layer_pixels(&s);
|
||||
let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::<Vec<_>>()).unwrap_or_default();
|
||||
assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas");
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas");
|
||||
assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tools_the_reference_application_does_not_wrap_stop_at_the_canvas_edge() {
|
||||
// Measured in the reference application: in Pattern Preview the Clone Stamp and Healing
|
||||
// Brush carry a stroke on across the canvas edges, but Blur … Sponge, Color Replacement,
|
||||
// Background Eraser, Pattern Stamp and History Brush warn that they don't wrap, and Spot
|
||||
// Healing stops at the edge too. Their `wrap` stroke over the corner paints as the same
|
||||
// stroke without it.
|
||||
let points = [[36.0, 24.0], [58.0, 39.0]];
|
||||
let cases = [
|
||||
("paint.blur", json!({"strength": 80})),
|
||||
("paint.sharpen", json!({"strength": 60})),
|
||||
("paint.smudge", json!({"strength": 70})),
|
||||
("paint.dodge", json!({"exposure": 60})),
|
||||
("paint.burn", json!({"exposure": 40})),
|
||||
("paint.sponge", json!({"mode": "saturate"})),
|
||||
("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60})),
|
||||
("paint.backgroundEraser", json!({"tolerance": 40})),
|
||||
("paint.patternStamp", json!({"pattern": "Checkerboard"})),
|
||||
("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7})),
|
||||
("paint.historyBrush", json!({"state": 1})),
|
||||
("paint.spotHealing", json!({"type": "contentAware"})),
|
||||
];
|
||||
for (cmd, extra) in cases {
|
||||
let run = |wrap: bool| {
|
||||
let mut s = session(WRAP.0, WRAP.1, 8, "rgb");
|
||||
paint_layer(&mut s, tiled_texture);
|
||||
// Painted over since history state 1, so the History Brush has something to bring back.
|
||||
paint_layer(&mut s, |x, y| tiled_texture(x + 7, y + 5));
|
||||
let mut p = dab_opts(&points, 100, &extra);
|
||||
p["size"] = json!(12);
|
||||
p["wrap"] = json!(wrap);
|
||||
let before = layer_pixels(&s);
|
||||
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
(before, layer_pixels(&s))
|
||||
};
|
||||
let ((before, plain), (_, wrapped)) = (run(false), run(true));
|
||||
assert!(plain != before, "{cmd} {extra}: the stroke paints inside the canvas");
|
||||
assert!(wrapped == plain, "{cmd} {extra}: nothing comes back at the opposite edges");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() {
|
||||
// The History Brush and aligned Pattern Stamp previews stop at the edge as their commits do.
|
||||
let points = [[36.0, 24.0], [58.0, 39.0]];
|
||||
let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect();
|
||||
for (cmd, extra) in [("paint.historyBrush", json!({"state": 1})), ("paint.patternStamp", json!({"pattern": "Checkerboard"}))] {
|
||||
let bounds = |wrap: bool| {
|
||||
let mut s = session(WRAP.0, WRAP.1, 8, "rgb");
|
||||
paint_layer(&mut s, tiled_texture);
|
||||
paint_layer(&mut s, |_, _| [0.15, 0.2, 0.7, 1.0]);
|
||||
let mut p = dab_opts(&points[..1], 100, &extra);
|
||||
p["size"] = json!(12);
|
||||
p["wrap"] = json!(wrap);
|
||||
let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap();
|
||||
live.push(&pts[1..]).unwrap();
|
||||
live.bounds()
|
||||
};
|
||||
assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod modify_window;
|
||||
mod color_range_performance;
|
||||
#[cfg(test)]
|
||||
mod color_range_window_tests;
|
||||
#[cfg(test)]
|
||||
mod wand_seed_outside_tests;
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
@@ -139,9 +141,15 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(plane) => plane,
|
||||
None => sample_rgba8(s, b(p, "sampleAllLayers", false))?,
|
||||
};
|
||||
let region = sel::wand_region(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true));
|
||||
drop(img);
|
||||
let m = mode(p);
|
||||
// Subtract and Intersect grow the region from the seed through the selection only (#11779).
|
||||
// A global match (Contiguous off) ignores the limit, so it skips the full-area mask.
|
||||
let contiguous = b(p, "contiguous", true);
|
||||
let within = (contiguous && matches!(m, SelectionMode::Subtract | SelectionMode::Intersect))
|
||||
.then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area)))
|
||||
.flatten();
|
||||
let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), contiguous, b(p, "antiAlias", true), within.as_deref());
|
||||
drop(img);
|
||||
let selected = s.edit("Magic Wand", |doc, _| {
|
||||
doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m);
|
||||
Ok(doc.selection.is_some())
|
||||
@@ -622,10 +630,33 @@ fn lasso(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() });
|
||||
}
|
||||
// The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop).
|
||||
let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" };
|
||||
// The Patch and Content-Aware Move tools draw with this command and send their own name as
|
||||
// `historyName`, so the plain Lasso tool keeps "Lasso".
|
||||
// A bad name is refused even when `polygonal` picks the step's name.
|
||||
let named = match p.get("historyName") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) if history_name(n).is_some() => history_name(n),
|
||||
Some(_) => {
|
||||
let msg = "historyName must be 1-64 characters with a visible one, and no control, line-break or hidden format characters";
|
||||
return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: msg.into() });
|
||||
}
|
||||
};
|
||||
let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { named.unwrap_or("Lasso") };
|
||||
polygon_selection(s, "select.lasso", step, &pts, p)
|
||||
}
|
||||
|
||||
/// The History step name for `historyName` `n`: refused when it has a control or line-break
|
||||
/// character anywhere; otherwise without its invisible end padding (see
|
||||
/// [`photocraft_plugins::manifest::trim_name`]), so a name that looks like "Patch Tool" is "Patch Tool",
|
||||
/// and then held to the plug-in menu-label rule.
|
||||
fn history_name(n: &str) -> Option<&str> {
|
||||
if n.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) {
|
||||
return None;
|
||||
}
|
||||
let t = photocraft_plugins::manifest::trim_name(n);
|
||||
photocraft_plugins::manifest::printable_name(t).then_some(t)
|
||||
}
|
||||
|
||||
/// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as
|
||||
/// one history step called `label`.
|
||||
pub(crate) fn polygon_selection(s: &mut Session, cmd: &str, label: &str, pts: &[(f32, f32)], p: &Value) -> Result<Value> {
|
||||
@@ -756,7 +787,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"select.lasso",
|
||||
"Lasso",
|
||||
[],
|
||||
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##,
|
||||
r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters with a visible one, no control, line-break or hidden format characters, invisible padding at the ends (spaces, joiners, fillers, blank Braille and other default-ignorable blanks, and selectors cut off by them) trimmed; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##,
|
||||
has_doc,
|
||||
lasso
|
||||
),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! #11779: with Contiguous on, the Magic Wand's Subtract and Intersect grow the region from the
|
||||
//! seed through the current selection only. A seed outside the selection subtracts nothing and
|
||||
//! intersects to nothing.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// 800 × 600, white, with a grey ramp of ten 40 px blocks from x 100 (100, 105, …, 145).
|
||||
fn ramp() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
|
||||
for k in 0..10 {
|
||||
let v = 100 + 5 * k;
|
||||
s.execute("select.rect", json!({"x": 100 + 40 * k, "y": 350, "width": 40, "height": 100})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": format!("#{v:02x}{v:02x}{v:02x}")})).unwrap();
|
||||
}
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) {
|
||||
s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap();
|
||||
}
|
||||
|
||||
fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) {
|
||||
s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap();
|
||||
}
|
||||
|
||||
/// The columns of row 400 that are at least half selected: (first, end), or `None`. Every
|
||||
/// column in between must be selected too, so a hole in the run fails the test.
|
||||
fn selected(s: &Session) -> Option<(i32, i32)> {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let row = photocraft_geom::Rect::new(0, 400, 800, 401);
|
||||
let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row);
|
||||
let first = m.iter().position(|v| *v >= 0.5)?;
|
||||
let last = m.iter().rposition(|v| *v >= 0.5)?;
|
||||
assert!(m[first..=last].iter().all(|v| *v >= 0.5), "a hole in the selected run {first}..={last}");
|
||||
Some((first as i32, last as i32 + 1))
|
||||
}
|
||||
|
||||
/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them, plus
|
||||
/// the Anti-alias fringe: column 380 of grey 135 at about 251/255, as measured in #11776.
|
||||
fn ramp_selected() -> Session {
|
||||
let mut s = ramp();
|
||||
wand(&mut s, 110, 32.0, "replace");
|
||||
assert_eq!(selected(&s), Some((100, 381)));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_outside_the_selection_subtracts_nothing() {
|
||||
let mut s = ramp_selected();
|
||||
// Grey 145 (x 460–500) is outside the selection, though its own region reaches into it.
|
||||
wand(&mut s, 480, 32.0, "subtract");
|
||||
assert_eq!(selected(&s), Some((100, 381)), "unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_outside_the_selection_intersects_to_nothing() {
|
||||
let mut s = ramp_selected();
|
||||
wand(&mut s, 480, 32.0, "intersect");
|
||||
assert_eq!(selected(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_inside_the_selection_works_as_before() {
|
||||
// Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380.
|
||||
// With the default Anti-alias fringe, as measured in #11779: Subtract leaves 100–219 (x 219
|
||||
// keeps about 6/255, under the 0.5 cut) and Intersect 219–381.
|
||||
let mut s = ramp_selected();
|
||||
wand(&mut s, 320, 10.0, "subtract");
|
||||
assert_eq!(selected(&s), Some((100, 219)));
|
||||
let mut s = ramp_selected();
|
||||
wand(&mut s, 320, 10.0, "intersect");
|
||||
assert_eq!(selected(&s), Some((219, 381)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_off_keeps_the_global_match() {
|
||||
// Without Contiguous the seed's colour matches everywhere, as before: greys 115–145 are
|
||||
// x 220–500, wherever the seed lies.
|
||||
let mut s = ramp_selected();
|
||||
wand_global(&mut s, 480, 32.0, "subtract");
|
||||
assert_eq!(selected(&s), Some((100, 219)));
|
||||
let mut s = ramp_selected();
|
||||
wand_global(&mut s, 480, 32.0, "intersect");
|
||||
assert_eq!(selected(&s), Some((219, 381)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_selection_subtract_and_intersect_select_nothing() {
|
||||
for mode in ["subtract", "intersect"] {
|
||||
let mut s = ramp();
|
||||
wand(&mut s, 320, 10.0, mode);
|
||||
assert_eq!(selected(&s), None, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_passes_only_pixels_at_least_half_selected() {
|
||||
use photocraft_algo::selection::wand_region_within;
|
||||
use photocraft_geom::Rect;
|
||||
// One flat grey row of four pixels; only the coverage limit decides the region.
|
||||
let img = [[128u8, 128, 128, 255]; 4];
|
||||
let area = Rect::new(0, 0, 4, 1);
|
||||
let fill = |within: [f32; 4], seed: i32| wand_region_within(&img, area, (seed, 0), 0.0, true, false, Some(&within));
|
||||
assert!(fill([0.49, 1.0, 1.0, 1.0], 0).is_none(), "a seed just under half selected finds nothing");
|
||||
let r = fill([0.5, 1.0, 1.0, 1.0], 0).unwrap();
|
||||
assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::<Vec<_>>(), [true; 4], "half selected counts");
|
||||
let r = fill([1.0, 1.0, 0.49, 1.0], 0).unwrap();
|
||||
assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::<Vec<_>>(), [true, true, false, false], "the fill stops at 0.49");
|
||||
}
|
||||
@@ -129,7 +129,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
|
||||
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
|
||||
let extent = canvas.union(&placed.content_bounds());
|
||||
for f in sm.smart_filters.iter().filter(|f| f.visible) {
|
||||
cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?;
|
||||
cur = apply_smart_filter(f, sm, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?;
|
||||
}
|
||||
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
|
||||
@@ -155,8 +155,9 @@ fn renders_in_object(f: &SmartFilter) -> bool {
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
|
||||
/// requesting session's authority denies the filter's params: callers surface the error instead of
|
||||
/// rendering (and caching) unfiltered pixels.
|
||||
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result<Surface> {
|
||||
let out = match crate::filters::apply_filter_to_surface_in(&f.command, &f.params, cur, canvas, None, extent, ctx)? {
|
||||
fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result<Surface> {
|
||||
let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm);
|
||||
let out = match crate::filters::apply_filter_to_surface_in(&f.command, ¶ms, cur, canvas, None, extent, ctx)? {
|
||||
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
|
||||
Some(out) => out,
|
||||
None => cur.clone(),
|
||||
@@ -367,7 +368,7 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul
|
||||
Some(b) => b,
|
||||
None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)),
|
||||
};
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent };
|
||||
cur = Stage { surface: apply_smart_filter(f, sm, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent };
|
||||
stack_cache::put(*key, &cur);
|
||||
}
|
||||
ctx.progress(1.0, "");
|
||||
|
||||
@@ -223,24 +223,24 @@ fn bad_layers_params_are_errors_not_panics() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straighten_layer_rotates_every_selected_layer() {
|
||||
// Photoshop's Straighten Layer turns all the selected layers, not just the active one; it
|
||||
// goes through `edit.transform` without a layer, so it takes the selection like Free Transform.
|
||||
fn straighten_layer_wants_one_selected_layer() {
|
||||
// #12033: the reference greys Straighten Layer out while two layers are selected, so it no
|
||||
// longer turns the whole selection the way Free Transform does
|
||||
// (`free_transform_moves_every_selected_layer_in_one_step` covers that).
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text));
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
assert_eq!(r["cropped"], false);
|
||||
assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too");
|
||||
assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step");
|
||||
assert!(s.undo());
|
||||
assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back");
|
||||
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected, the type layer active");
|
||||
assert!(!s.is_enabled("image.analysis.straightenLayer"), "two selected layers");
|
||||
assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err());
|
||||
assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "nothing turned");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps, "and no history step");
|
||||
|
||||
// With one layer selected, only that layer turns.
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
assert!(s.is_enabled("image.analysis.straightenLayer"), "a single layer");
|
||||
s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
assert!(bounds(&s, paint) != p0);
|
||||
assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat};
|
||||
use photocraft_doc::text::{
|
||||
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
|
||||
TextRun, TextShape,
|
||||
@@ -937,6 +937,56 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result<TextLa
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The name of the history state a new type layer makes: what `type.create` records, and what the
|
||||
/// Type tool's commit keeps in the modes [`commits_as_selection`] names (#12041).
|
||||
pub const NEW_TYPE_LAYER_LABEL: &str = "New Type Layer";
|
||||
|
||||
/// Whether committing Type-tool text `t` in colour mode `mode` leaves no type layer, but a
|
||||
/// selection in the shape of its glyphs, as Photoshop does in its Indexed Color and Bitmap
|
||||
/// documents (#12041).
|
||||
///
|
||||
/// Both halves of the answer come from the issue:
|
||||
/// - the modes: Indexed Color and Bitmap are the two documents that keep no type layer;
|
||||
/// - the text: only the horizontal point text the measurement describes - the tool "still lets you
|
||||
/// click and type" and the commit leaves "a selection in the shape of the typed glyphs". Paragraph
|
||||
/// (box) text and vertical type are on its *Not measured* list, whose rule is that an unmeasured
|
||||
/// case keeps today's behaviour - and today's behaviour is the outcome that makes sense here
|
||||
/// anyway: a coverage mask cannot give back a box that reflows or a column of stacked lines, so
|
||||
/// the selection would end the editability the layer exists for. The Type Mask tools keep turning
|
||||
/// both into the selection, as they do today: there the user asked for one.
|
||||
///
|
||||
/// The check belongs to the Type tool's commit (the Type Mask tools' `type.createSelection` with
|
||||
/// `removeLayer`, which it already runs for `ed.mask`) and *not* to the `type.create` command, even
|
||||
/// though a colour-mode check there is what looks simplest:
|
||||
/// - `type.create` is the primitive the tools build the layer they edit and show on canvas with
|
||||
/// (`pointer_up` in `ui-egui`), and Preferences > Type > "Fill new type layers with placeholder
|
||||
/// text" (on by default) already gives that first call non-empty text. Branching there would make
|
||||
/// the click produce a selection and no layer, so there would be nothing to type into - while the
|
||||
/// reference application still lets you click and type in these modes and shows the text while
|
||||
/// editing. Only its commit differs.
|
||||
/// - `type.create` is also what automation, actions and a script's new text layer go through, and
|
||||
/// the measured difference is the tool's.
|
||||
pub fn commits_as_selection(mode: ColorMode, t: &TextLayer) -> bool {
|
||||
let measured = matches!(t.shape, TextShape::Point) && t.orientation == Orientation::Horizontal;
|
||||
measured && matches!(mode, ColorMode::Indexed | ColorMode::Bitmap)
|
||||
}
|
||||
|
||||
/// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type
|
||||
/// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the
|
||||
/// one step of the typing session - and that command names the step "Type Mask", while Photoshop's
|
||||
/// history state here is the type layer's own name.
|
||||
///
|
||||
/// An edit that does not carry the session's `coalesce` key (the Alt+arrow kerning step, which is
|
||||
/// deliberately one state per press) ends the session's step, exactly as it does for the Type Mask
|
||||
/// tools and for the plain type layer's rename on commit; the conversion is then a state of its own
|
||||
/// and takes this name too. Photoshop repeats a state name whenever the same thing happens again,
|
||||
/// so the two states read as the two type-layer steps they are.
|
||||
pub fn name_selection_commit(s: &mut Session) {
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.history.set_current_label(NEW_TYPE_LAYER_LABEL);
|
||||
}
|
||||
}
|
||||
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -953,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut t = new_text_layer(s, p, "type.create")?;
|
||||
let given = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
let name = given.clone().unwrap_or_else(|| layer_name(&t.text));
|
||||
let id = s.edit("New Type Layer", |doc, active| {
|
||||
let id = s.edit(NEW_TYPE_LAYER_LABEL, |doc, active| {
|
||||
refresh(doc, &mut t);
|
||||
let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable);
|
||||
*active = Some(id);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! #12041: committing Type-tool text in an Indexed Color or a Bitmap document.
|
||||
//!
|
||||
//! The reference application still lets you click and type there and shows the text while editing;
|
||||
//! the commit leaves **no type layer** and a selection in the shape of the glyphs, as one history
|
||||
//! state named "New Type Layer", and paints nothing. [`super::type_cmds::commits_as_selection`] is
|
||||
//! that mode check and [`super::type_cmds::name_selection_commit`] the step's name; the conversion
|
||||
//! is the Type Mask tools' `type.createSelection` with `removeLayer`, coalesced into the typing
|
||||
//! session, which is what [`commit`] below runs and what `ui-egui`'s `commit_here` now does for the
|
||||
//! Type tool in these modes.
|
||||
//!
|
||||
//! `type.create` itself stays mode-agnostic on purpose, and
|
||||
//! [`the_type_layer_command_keeps_making_the_tools_working_layer`] pins that: the tools make the
|
||||
//! layer they edit and show on canvas with that command, on the first click already with text in it
|
||||
//! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a
|
||||
//! mode branch there would take away the layer the session types into.
|
||||
//!
|
||||
//! The issue measures the horizontal point text you click and type, and lists paragraph text and
|
||||
//! vertical type under *Not measured*, where its rule is that today's behaviour stays:
|
||||
//! [`only_the_measured_horizontal_point_text_converts_in_these_modes`] pins that half of the check,
|
||||
//! and [`an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states`] what happens to the
|
||||
//! states when an edit outside the session's coalesce group comes between the typing and the commit.
|
||||
|
||||
use super::type_cmds::{commits_as_selection, name_selection_commit, new_text_layer};
|
||||
use super::*;
|
||||
use photocraft_doc::text::{Orientation, TextShape};
|
||||
|
||||
/// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one.
|
||||
const SESSION: &str = "type-session-12041";
|
||||
|
||||
/// The text the tool commits, at a baseline inside every document used here. `coalesce` for the
|
||||
/// tool's own calls (one step for the session), without it for a script's or the pin's.
|
||||
fn typed(coalesce: bool) -> Value {
|
||||
let mut p = json!({ "text": "Hi", "x": 20, "y": 80, "size": 48 });
|
||||
if coalesce {
|
||||
p["coalesce"] = json!(SESSION);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A blank RGB document, to be converted to the mode under test.
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 240, "height": 120, "background": "transparent"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn indexed() -> Session {
|
||||
let mut s = blank();
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap();
|
||||
assert_eq!(mode(&s), ColorMode::Indexed, "the setup made an Indexed Color document");
|
||||
s
|
||||
}
|
||||
|
||||
fn bitmap() -> Session {
|
||||
let mut s = blank();
|
||||
s.execute("image.mode.grayscale", json!({})).unwrap();
|
||||
s.execute("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap();
|
||||
assert_eq!(mode(&s), ColorMode::Bitmap, "the setup made a Bitmap document");
|
||||
s
|
||||
}
|
||||
|
||||
fn mode(s: &Session) -> ColorMode {
|
||||
s.active().unwrap().doc.mode
|
||||
}
|
||||
|
||||
/// The Type tool's commit of the session's working layer in a document [`commits_as_selection`]
|
||||
/// names: the Type Mask tools' conversion, in the session's one step, which keeps the name the step
|
||||
/// was opened with.
|
||||
fn commit(s: &mut Session, layer: u64) {
|
||||
s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).unwrap();
|
||||
name_selection_commit(s);
|
||||
}
|
||||
|
||||
fn text_layers(s: &Session) -> usize {
|
||||
s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count()
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.active().unwrap().doc.walk().len()
|
||||
}
|
||||
|
||||
/// The selection's coverage over the whole canvas, in document pixels.
|
||||
fn coverage(s: &Session) -> Vec<f32> {
|
||||
let d = s.active().unwrap().doc.clone();
|
||||
let b = d.bounds();
|
||||
let mut out = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
out.push(d.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The selection's bounds, as its corners.
|
||||
fn selection_corners(s: &Session) -> Option<[i32; 4]> {
|
||||
s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()).map(|r| [r.x0, r.y0, r.x1, r.y1])
|
||||
}
|
||||
|
||||
/// Every layer's pixels, quantised: what the commit must leave exactly as it was.
|
||||
fn pixels(s: &Session) -> Vec<u8> {
|
||||
let d = s.active().unwrap().doc.clone();
|
||||
let b = d.bounds();
|
||||
let mut out = Vec::new();
|
||||
for (_, _, l) in d.walk() {
|
||||
let Some(surface) = l.surface() else { continue };
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
out.extend(surface.rgba(x, y).map(|v| (v * 255.0).round() as u8));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn history(s: &Session) -> Vec<String> {
|
||||
s.active().unwrap().history.entries()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected_and_no_layer() {
|
||||
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
|
||||
let mut s = make();
|
||||
let (steps, layers_before, pixels_before) = (history(&s).len(), layers(&s), pixels(&s));
|
||||
|
||||
// The tool's session: a type layer to edit and show while typing, as measured.
|
||||
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(text_layers(&s), 1, "{name}: typing happens on a layer the tool can show");
|
||||
|
||||
commit(&mut s, layer);
|
||||
|
||||
assert_eq!(text_layers(&s), 0, "{name}: committing leaves no type layer");
|
||||
assert_eq!(layers(&s), layers_before, "{name}: the working layer went with it");
|
||||
assert!(s.active().unwrap().doc.selection.is_some(), "{name}: the glyphs are selected");
|
||||
let selected = s.active().unwrap().doc.selection.as_ref().unwrap();
|
||||
assert!(!selected.content_bounds().is_empty(), "{name}: the selection has a shape");
|
||||
assert!(coverage(&s).iter().any(|v| *v > 0.0), "{name}: the selected pixels are the glyphs'");
|
||||
assert_eq!(pixels(&s), pixels_before, "{name}: the commit paints nothing");
|
||||
|
||||
// One step for the whole session, named as Photoshop names it.
|
||||
let h = history(&s);
|
||||
assert_eq!(h.len(), steps + 1, "{name}: the session is one history state: {h:?}");
|
||||
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: its name");
|
||||
|
||||
// And undoing that one state gives the document back as it was.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: undo takes the selection");
|
||||
assert_eq!(layers(&s), layers_before, "{name}: undo takes the whole session");
|
||||
assert_eq!(pixels(&s), pixels_before, "{name}: nothing else was changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_the_glyph_shape_the_type_mask_of_the_same_text_makes() {
|
||||
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
|
||||
let mut s = make();
|
||||
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
|
||||
commit(&mut s, layer);
|
||||
let (got, got_bounds) = (coverage(&s), selection_corners(&s));
|
||||
|
||||
// The pin: the same text straight through the Type Mask command.
|
||||
let mut pin = make();
|
||||
pin.execute("type.createSelection", typed(false)).unwrap();
|
||||
assert_eq!(got_bounds, selection_corners(&pin), "{name}: same selection bounds");
|
||||
assert_eq!(got, coverage(&pin), "{name}: same selection coverage");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() {
|
||||
for (id, want) in [("rgb", ColorMode::Rgb), ("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap();
|
||||
assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode");
|
||||
let point = new_text_layer(&s, &typed(false), "type.create").unwrap();
|
||||
assert!(!commits_as_selection(want, &point), "{id} keeps type layers");
|
||||
|
||||
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
|
||||
// Today's commit: rename the session's layer in the same step, select nothing.
|
||||
let name = super::type_cmds::layer_name("Hi");
|
||||
s.execute("type.edit", json!({"layer": layer, "name": name, "coalesce": SESSION})).unwrap();
|
||||
assert_eq!(text_layers(&s), 1, "{id} at {depth} bit keeps its type layer");
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{id} at {depth} bit selects nothing");
|
||||
let h = history(&s);
|
||||
assert_eq!(h.len(), 2, "{id} at {depth} bit: open plus one step for the session: {h:?}");
|
||||
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{id} at {depth} bit keeps the step's name");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_layer_command_keeps_making_the_tools_working_layer_in_these_modes() {
|
||||
// `type.create` is the primitive the Type tools create the layer they edit with (and scripts and
|
||||
// actions their text layers with), so the colour-mode branch is the commit's, not its own.
|
||||
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
|
||||
let mut s = make();
|
||||
let id = s.execute("type.create", typed(false)).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Text(_)), "{name}: the command still makes the layer");
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: and selects nothing itself");
|
||||
assert_eq!(history(&s).last().map(String::as_str), Some("New Type Layer"), "{name}: its own step name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() {
|
||||
// The commit's fallback in `ui-egui` keeps today's type layer when the conversion is refused,
|
||||
// so the typed text is not lost with a selection that was never made. Here the document would
|
||||
// have to keep no layer at all, which is what `type.createSelection` with `removeLayer` refuses.
|
||||
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
|
||||
let mut s = make();
|
||||
let only = s.active().unwrap().doc.layers[0].id.0;
|
||||
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.delete", json!({"layer": only})).unwrap();
|
||||
let (pixels_before, steps, label) = (pixels(&s), history(&s).len(), history(&s).last().cloned());
|
||||
|
||||
assert!(
|
||||
s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).is_err(),
|
||||
"{name}: the conversion is refused"
|
||||
);
|
||||
// `commit_here` stops there and keeps the layer, so the step is never renamed either.
|
||||
assert_eq!(text_layers(&s), 1, "{name}: the typed text stays as a type layer");
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: nothing was selected");
|
||||
assert_eq!(pixels(&s), pixels_before, "{name}: the refused step painted nothing");
|
||||
assert_eq!(history(&s).len(), steps, "{name}: the refused step is no new state");
|
||||
assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// The measurement covers horizontal point text. Paragraph (box) text and vertical type are on the
|
||||
/// issue's *Not measured* list, where its rule is that today's behaviour stays - and a coverage mask
|
||||
/// could not give a reflowing box or a stacked column back anyway, so their editable layer stays.
|
||||
#[test]
|
||||
fn only_the_measured_horizontal_point_text_converts_in_these_modes() {
|
||||
let s = indexed();
|
||||
let point = new_text_layer(&s, &typed(false), "type.create").unwrap();
|
||||
assert!(matches!(point.shape, TextShape::Point) && point.orientation == Orientation::Horizontal, "the fixture is horizontal point text");
|
||||
|
||||
let mut box_params = typed(false);
|
||||
box_params["box"] = json!([20.0, 20.0, 180.0, 80.0]);
|
||||
let paragraph = new_text_layer(&s, &box_params, "type.create").unwrap();
|
||||
assert!(matches!(paragraph.shape, TextShape::Box { .. }), "the fixture is paragraph text");
|
||||
|
||||
let mut vertical_params = typed(false);
|
||||
vertical_params["orientation"] = json!("vertical");
|
||||
let vertical = new_text_layer(&s, &vertical_params, "type.create").unwrap();
|
||||
assert_eq!(vertical.orientation, Orientation::Vertical, "the fixture is vertical text");
|
||||
|
||||
for mode in [ColorMode::Indexed, ColorMode::Bitmap] {
|
||||
assert!(commits_as_selection(mode, &point), "{mode:?}: the measured text becomes the selection");
|
||||
assert!(!commits_as_selection(mode, ¶graph), "{mode:?}: paragraph text keeps its type layer");
|
||||
assert!(!commits_as_selection(mode, &vertical), "{mode:?}: vertical text keeps its type layer");
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit that does not carry the session's `coalesce` key ends its coalesce group - in the tool
|
||||
/// that is the deliberately per-press Alt+arrow kerning step. The commit then makes a state of its
|
||||
/// own, which is what the Type Mask tools' commit and the plain layer's rename do in the same
|
||||
/// situation today; nothing is mis-named and nothing else moves.
|
||||
#[test]
|
||||
fn an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states() {
|
||||
for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] {
|
||||
let mut s = make();
|
||||
let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap();
|
||||
// The stand-in for that kerning press: a real edit under no coalesce key.
|
||||
s.execute("type.edit", json!({"layer": layer, "antialias": "sharp"})).unwrap();
|
||||
let before = history(&s);
|
||||
assert_eq!(before.last().map(String::as_str), Some("Edit Type"), "{name}: the unkeyed edit is a state of its own");
|
||||
|
||||
commit(&mut s, layer);
|
||||
|
||||
let h = history(&s);
|
||||
assert_eq!(h.len(), before.len() + 1, "{name}: the commit is a further state once the group ended: {h:?}");
|
||||
assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: named for the type layer either way");
|
||||
assert_eq!(h[h.len() - 2].as_str(), "Edit Type", "{name}: the edit between them keeps its own name");
|
||||
assert_eq!(text_layers(&s), 0, "{name}: still no type layer is left");
|
||||
assert!(s.active().unwrap().doc.selection.is_some(), "{name}: still the glyphs are selected");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{name}: one undo takes the conversion");
|
||||
assert_eq!(text_layers(&s), 1, "{name}: which brings the working layer back, as the mask tools' undo does");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(text_layers(&s), 1, "{name}: the next undo takes the unkeyed edit, not the session");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(text_layers(&s), 0, "{name}: and the third one takes the typing session");
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: |s, p| path_to_selection_with_mode(s, p, "subtract"),
|
||||
journal: true,
|
||||
},
|
||||
// ⇧⌥⌘↩ / Ctrl+Alt+Shift+Enter intersects the path with the selection (#7726).
|
||||
CommandSpec {
|
||||
id: "path.intersectWithSelection",
|
||||
label: "Intersect Path with Selection",
|
||||
menu: &[],
|
||||
shortcut: Some("Cmd+Alt+Shift+Enter"),
|
||||
params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "intersect")"##,
|
||||
enabled: has_doc,
|
||||
run: |s, p| path_to_selection_with_mode(s, p, "intersect"),
|
||||
journal: true,
|
||||
},
|
||||
spec!(
|
||||
"path.toShape",
|
||||
"Make Shape from Path",
|
||||
|
||||
@@ -371,7 +371,8 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "selected": selected }))
|
||||
}
|
||||
|
||||
/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts);
|
||||
/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts,
|
||||
/// ⇧⌥⌘↩ intersects);
|
||||
/// a `mode` in the params is overridden, the rest is forwarded and validated as usual.
|
||||
pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
|
||||
let mut q = match p {
|
||||
|
||||
@@ -568,6 +568,26 @@ fn open_pen_paths_make_the_same_selection_as_closed_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
/// ⇧⌘↩, ⌥⌘↩ and ⇧⌥⌘↩'s commands are Make Selection from Path with their mode (#7726), whatever
|
||||
/// `mode` the params ask for.
|
||||
#[test]
|
||||
fn the_fixed_mode_path_commands_combine_as_their_mode() {
|
||||
let mut s = session(80, 80, 8);
|
||||
let square = |x: f64| path_json(&vector::shapes::rect(x, 20.0, 30.0, 30.0));
|
||||
s.execute("path.set", json!({"name": "Sq", "path": square(30.0)})).unwrap();
|
||||
for (id, mode) in [("path.addToSelection", "add"), ("path.subtractFromSelection", "subtract"), ("path.intersectWithSelection", "intersect")] {
|
||||
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap();
|
||||
s.execute("path.toSelection", json!({"name": "Sq", "mode": mode, "antiAlias": false})).unwrap();
|
||||
let want = selection_mask(&s);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap();
|
||||
s.execute(id, json!({"name": "Sq", "mode": "replace", "antiAlias": false})).unwrap();
|
||||
assert_eq!(selection_mask(&s), want, "{id} is mode {mode}");
|
||||
}
|
||||
// Intersecting keeps only the 10 px both squares cover.
|
||||
let mask = selection_mask(&s);
|
||||
assert_eq!(mask.iter().filter(|v| **v > 0.5).count(), 10 * 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_to_work_path_roundtrip_iou() {
|
||||
let mut s = session(240, 180, 8);
|
||||
|
||||
@@ -200,7 +200,7 @@ pub struct ToolState {
|
||||
|
||||
impl ToolState {
|
||||
/// How many CMYK picks are remembered.
|
||||
const CMYK_PICKS: usize = 8;
|
||||
pub const CMYK_PICKS: usize = 8;
|
||||
|
||||
/// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1).
|
||||
pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) {
|
||||
|
||||
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user