mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
Merge origin/main into fix/hue-range-zone-drag
This commit is contained in:
commit
bdcbb0fa93
286 files changed
+16947
-984
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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
//! 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]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1267,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")),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -957,6 +957,14 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor
|
||||
photocraft_io::ImportOptions {
|
||||
max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize,
|
||||
demosaic: prefs.raw_defaults.demosaic.method(),
|
||||
pdf_dpi: prefs.file_handling.pdf_dpi,
|
||||
pdf_pages: match (prefs.file_handling.pdf_first_page, prefs.file_handling.pdf_last_page) {
|
||||
(0, 0) => None,
|
||||
(first, last) => {
|
||||
let first = first.max(1);
|
||||
Some((first, if last == 0 { u32::MAX } else { last.max(first) }))
|
||||
}
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -2148,7 +2156,7 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../io/tests/support/recovery_psd.rs"]
|
||||
mod psd_open_fixture;
|
||||
pub(crate) mod psd_open_fixture;
|
||||
|
||||
#[cfg(test)]
|
||||
mod psd_open_confirmation_tests {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use photocraft_color::{BlendMode, Color};
|
||||
use photocraft_doc::adjust::CurvePoint;
|
||||
use photocraft_doc::effects::PATTERN_SCALE_RANGE;
|
||||
use photocraft_doc::{
|
||||
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx,
|
||||
StrokePosition,
|
||||
@@ -804,7 +805,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) {
|
||||
*id = String::new();
|
||||
}
|
||||
if let Some(x) = f_of(p, "scale") {
|
||||
*scale = (x / 100.0).clamp(0.01, 10.0);
|
||||
*scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1);
|
||||
}
|
||||
if let Some(x) = f_of(p, "angle") {
|
||||
*angle = x;
|
||||
@@ -912,7 +913,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) {
|
||||
bv.texture = Some(photocraft_doc::BevelTexture {
|
||||
name: t.to_string(),
|
||||
id: t.to_string(),
|
||||
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
|
||||
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1),
|
||||
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
|
||||
invert: b(p, "textureInvert", false),
|
||||
link: b(p, "textureLink", true),
|
||||
|
||||
@@ -346,6 +346,8 @@ mod embed_all_linked_tests;
|
||||
#[cfg(test)]
|
||||
mod fill_layer_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod mixer_indexed_bitmap_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod second_keys_zoom_actions_tests;
|
||||
@@ -355,4 +357,6 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod fx_mode_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod brush_clear_background_tests;
|
||||
pub mod command_hints;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The Mixer Brush refuses Indexed Color and Bitmap documents with the reference's alert texts
|
||||
//! (#11958): `paint.mixerBrush` returns the error and leaves the document and History unchanged.
|
||||
//! RGB, CMYK, Lab and Grayscale documents still paint.
|
||||
|
||||
use crate::Session;
|
||||
use crate::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL};
|
||||
use serde_json::json;
|
||||
|
||||
fn stroke(s: &mut Session) -> crate::Result<serde_json::Value> {
|
||||
s.execute("paint.mixerBrush", json!({"points": [[10, 10], [40, 20]], "size": 12, "wet": 0, "mix": 0}))
|
||||
}
|
||||
|
||||
fn assert_refused(s: &mut Session, text: &str) {
|
||||
let (doc, steps) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.past_len());
|
||||
let err = stroke(s).unwrap_err().to_string();
|
||||
assert_eq!(err, text);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc), "nothing painted");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps, "no history step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_color_documents_are_refused_with_the_alert() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
|
||||
s.execute("image.mode.indexedColor", json!({})).unwrap();
|
||||
assert_refused(&mut s, INDEXED_REFUSAL);
|
||||
assert_eq!(
|
||||
INDEXED_REFUSAL,
|
||||
"Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_documents_are_refused_with_the_alert() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
|
||||
s.execute("image.mode.grayscale", json!({})).unwrap();
|
||||
s.execute("image.mode.bitmap", json!({})).unwrap();
|
||||
assert_refused(&mut s, BITMAP_REFUSAL);
|
||||
assert_eq!(BITMAP_REFUSAL, "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit).");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_cmyk_lab_and_grayscale_documents_still_paint() {
|
||||
let mut painted = 0;
|
||||
for mode in ["rgb", "cmyk", "lab", "grayscale"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
// Not every mode has every depth (as in the reference, CMYK and Lab stop at 16 bits).
|
||||
if s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth, "background": "white"})).is_err() {
|
||||
continue;
|
||||
}
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
stroke(&mut s).unwrap_or_else(|e| panic!("{mode} {depth}: {e}"));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{mode} {depth}");
|
||||
painted += 1;
|
||||
}
|
||||
}
|
||||
assert!(painted >= 8, "only {painted} mode/depth combinations painted");
|
||||
}
|
||||
@@ -570,6 +570,12 @@ pub struct FileHandling {
|
||||
pub recover_on_launch: bool,
|
||||
/// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB.
|
||||
pub ignore_exif_profile_tag: bool,
|
||||
/// PDF import resolution in DPI; the document keeps it so the page's print size is preserved.
|
||||
pub pdf_dpi: f32,
|
||||
/// The first PDF page to import (1-based; 0 imports from the first page).
|
||||
pub pdf_first_page: u32,
|
||||
/// The last PDF page to import (1-based; 0 imports to the last page).
|
||||
pub pdf_last_page: u32,
|
||||
/// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR
|
||||
/// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead.
|
||||
pub exr_aces_cg: bool,
|
||||
@@ -607,6 +613,9 @@ impl Default for FileHandling {
|
||||
autosave_minutes: 10,
|
||||
recover_on_launch: true,
|
||||
ignore_exif_profile_tag: false,
|
||||
pdf_dpi: 300.0,
|
||||
pdf_first_page: 0,
|
||||
pdf_last_page: 0,
|
||||
exr_aces_cg: false,
|
||||
ask_before_saving_layered_tiff: true,
|
||||
maximize_psd_compatibility: Ask::Always,
|
||||
@@ -630,6 +639,10 @@ pub struct Export {
|
||||
pub webp_quality: u32,
|
||||
pub metadata: ExportMetadata,
|
||||
pub convert_to_srgb: bool,
|
||||
/// The folder the last successful export through a save dialog went to (Export As, Quick
|
||||
/// Export, Save for Web, ...). The next export dialog starts there; empty until the first
|
||||
/// export, and ignored while the folder no longer exists (#10192).
|
||||
pub last_folder: String,
|
||||
}
|
||||
|
||||
impl Default for Export {
|
||||
@@ -642,6 +655,7 @@ impl Default for Export {
|
||||
webp_quality: 85,
|
||||
metadata: ExportMetadata::Copyright,
|
||||
convert_to_srgb: true,
|
||||
last_folder: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@ impl Session {
|
||||
self.next_held_open
|
||||
}
|
||||
|
||||
/// Whether a document held back for its prompt already has `name`.
|
||||
pub(crate) fn held_open_named(&self, name: &str) -> bool {
|
||||
self.held_opens.iter().any(|(_, d, _)| d.name == name)
|
||||
}
|
||||
|
||||
/// Takes a document a background open held back for the profile prompt: the document and
|
||||
/// whether its source is read-only. `None` when there is no such document (already taken).
|
||||
pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> {
|
||||
|
||||
@@ -169,6 +169,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
|
||||
("tools.select.triangle", "Triangle Tool", Some("U")),
|
||||
("tools.select.polygon", "Polygon Tool", Some("U")),
|
||||
("tools.select.star", "Star Tool", Some("U")),
|
||||
("tools.select.line", "Line Tool", Some("U")),
|
||||
("tools.select.customShape", "Custom Shape Tool", Some("U")),
|
||||
("tools.select.slice", "Slice Tool", Some("C")),
|
||||
|
||||
@@ -99,6 +99,12 @@ fn adjustments_filters_and_drawing_preserve_other_encoded_components() {
|
||||
let mut s = setup(mode, depth);
|
||||
subset(&mut s);
|
||||
let before = surface(&s);
|
||||
// Color Replacement refuses 32-bit RGB, as the reference application does (#11923).
|
||||
if *cmd == "paint.colorReplacement" && mode == "rgb" && depth == 32 {
|
||||
assert!(s.execute(cmd, p.clone()).is_err(), "{mode} {depth} {cmd} is refused");
|
||||
assert_eq!(surface(&s), before, "{mode} {depth} {cmd}: nothing changed");
|
||||
continue;
|
||||
}
|
||||
s.execute(cmd, p.clone()).unwrap_or_else(|e| panic!("{mode} {depth} {cmd}: {e}"));
|
||||
let after = surface(&s);
|
||||
unchanged(&before, &after);
|
||||
@@ -669,7 +675,8 @@ fn last_filter_rejects_non_object_journal_params_without_panicking() {
|
||||
#[test]
|
||||
fn first_single_channel_retouch_preview_preserves_every_other_sample() {
|
||||
for cmd in ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.colorReplacement"] {
|
||||
let mut s = setup("rgb", 32);
|
||||
// Color Replacement refuses 32-bit RGB (#11923): it is checked at 16 bits.
|
||||
let mut s = setup("rgb", if cmd == "paint.colorReplacement" { 16 } else { 32 });
|
||||
s.execute("channel.target", json!({"channel":{"color":0}})).unwrap();
|
||||
let before = surface(&s);
|
||||
let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","tolerance":100});
|
||||
@@ -765,7 +772,11 @@ fn lab_single_channel_tone_parameters_round_trip_without_applying_lightness_twic
|
||||
}
|
||||
|
||||
fn flat_rgb() -> Session {
|
||||
let mut s = setup("rgb", 32);
|
||||
flat_rgb_at(32)
|
||||
}
|
||||
|
||||
fn flat_rgb_at(depth: u32) -> Session {
|
||||
let mut s = setup("rgb", depth);
|
||||
s.edit("flat", |doc, id| {
|
||||
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.25, 0.5, 0.25, 1.0]);
|
||||
Ok(())
|
||||
@@ -894,7 +905,8 @@ fn rgb_component_exposure_uses_the_document_transfer_at_all_depths() {
|
||||
#[test]
|
||||
fn color_replacement_live_preview_forwards_explicit_component_and_pixel_targets() {
|
||||
for target in [json!({"colors":[1]}), json!("pixels")] {
|
||||
let mut s = flat_rgb();
|
||||
// 16-bit: Color Replacement refuses 32-bit RGB (#11923).
|
||||
let mut s = flat_rgb_at(16);
|
||||
subset(&mut s);
|
||||
let params = json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff","seed":42,"target":target});
|
||||
let live = photocraft_engine::brush_cmds::live_color_replacement(&s, ¶ms).unwrap();
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale
|
||||
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 };
|
||||
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 };
|
||||
let a = f64::from(angle).to_radians();
|
||||
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ corpus = []
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
# Test support for synthetic memory injection, estimate charges and verification across crates.
|
||||
testgen = []
|
||||
# Editable PDF import (experimental): painted paths and embedded-font text become editable
|
||||
# layers; unsupported constructs keep the raster page.
|
||||
pdf-editable = ["dep:kurbo"]
|
||||
|
||||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
@@ -51,6 +54,11 @@ imagesize = "0.13"
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] }
|
||||
# The SVG fallback font search keeps each font file's character map (already in the tree through usvg).
|
||||
ttf-parser = { version = "0.25", default-features = false }
|
||||
# PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships.
|
||||
craft-pdf = { workspace = true }
|
||||
# Editable PDF import (pdf_editable.rs): craft-pdf's Device trait uses kurbo paths and transforms.
|
||||
# This is the same kurbo 0.13 that craft-pdf already links; naming it adds no crate to the graph.
|
||||
kurbo = { version = "0.13", optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature
|
||||
//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no
|
||||
//! counterpart and are ignored.
|
||||
//!
|
||||
//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` /
|
||||
//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its
|
||||
//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the
|
||||
//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw
|
||||
//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and
|
||||
//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied.
|
||||
|
||||
use photocraft_algo::camera_raw::{CameraRaw, validate_curve};
|
||||
|
||||
@@ -126,7 +133,6 @@ const METADATA: &[&str] = &[
|
||||
"Version",
|
||||
"ProcessVersion",
|
||||
"HasSettings",
|
||||
"HasCrop",
|
||||
"WhiteBalance",
|
||||
"ToneCurveName2012",
|
||||
"CameraProfile",
|
||||
@@ -147,16 +153,114 @@ const METADATA: &[&str] = &[
|
||||
"SortName",
|
||||
"Group",
|
||||
"Description",
|
||||
// The raw file the sidecar belongs to (Lightroom and Camera Raw name it).
|
||||
"RawFileName",
|
||||
];
|
||||
|
||||
/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level).
|
||||
pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"];
|
||||
const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0];
|
||||
|
||||
/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before
|
||||
/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and
|
||||
/// `0 <= top < bottom <= 1`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Crop {
|
||||
pub top: f32,
|
||||
pub left: f32,
|
||||
pub bottom: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
/// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1),
|
||||
/// as fractions of the upright frame: the corners map as the pixels do.
|
||||
pub fn oriented(self, o: u16) -> Crop {
|
||||
let map = |u: f32, v: f32| match o {
|
||||
2 => (1.0 - u, v),
|
||||
3 => (1.0 - u, 1.0 - v),
|
||||
4 => (u, 1.0 - v),
|
||||
5 => (v, u),
|
||||
6 => (1.0 - v, u),
|
||||
7 => (1.0 - v, 1.0 - u),
|
||||
8 => (v, 1.0 - u),
|
||||
_ => (u, v),
|
||||
};
|
||||
let (ax, ay) = map(self.left, self.top);
|
||||
let (bx, by) = map(self.right, self.bottom);
|
||||
Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) }
|
||||
}
|
||||
|
||||
/// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive:
|
||||
/// at least one pixel, inside the frame. `None` for an empty frame.
|
||||
pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> {
|
||||
let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n);
|
||||
let span = |a: f32, b: f32, n: usize| {
|
||||
let lo = edge(a, n).min(n.checked_sub(1)?);
|
||||
Some((lo, edge(b, n).max(lo + 1)))
|
||||
};
|
||||
let (x0, x1) = span(self.left, self.right, w)?;
|
||||
let (y0, y1) = span(self.top, self.bottom, h)?;
|
||||
Some((x0, y0, x1, y1))
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`load`] applied.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LoadReport {
|
||||
/// How many settings were applied.
|
||||
/// How many settings were applied (a crop counts as one).
|
||||
pub applied: usize,
|
||||
/// `crs:` settings without a PhotoCraft control (set to something other than their neutral
|
||||
/// value), or with an unusable value, in file order.
|
||||
/// value), or with an unusable value, in file order; then the keys of a crop that isn't
|
||||
/// applied (straightened, unreadable or empty).
|
||||
pub ignored: Vec<String>,
|
||||
/// The file's crop, when it has one that isn't the whole frame. The settings hold no crop,
|
||||
/// so the caller applies it (a raw open crops the document) or reports it.
|
||||
pub crop: Option<Crop>,
|
||||
}
|
||||
|
||||
/// The crop keys as read, resolved by [`crop`] once the whole file is read.
|
||||
#[derive(Default)]
|
||||
struct CropFields {
|
||||
has: Option<String>,
|
||||
values: [Option<String>; 5],
|
||||
}
|
||||
|
||||
/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level,
|
||||
/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported.
|
||||
fn crop(fields: &CropFields, report: &mut LoadReport) {
|
||||
match fields.has.as_deref().map(str::trim) {
|
||||
// No crop: the other keys are what Camera Raw left from an earlier or reset crop.
|
||||
None => return,
|
||||
Some(v) if v.eq_ignore_ascii_case("false") => return,
|
||||
Some(v) if v.eq_ignore_ascii_case("true") => {}
|
||||
Some(_) => {
|
||||
report.ignored.push("HasCrop".into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut v = NO_CROP;
|
||||
let mut usable = true;
|
||||
for (slot, text) in v.iter_mut().zip(&fields.values) {
|
||||
if let Some(text) = text {
|
||||
match number(text) {
|
||||
Some(n) => *slot = n,
|
||||
None => usable = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0));
|
||||
// A straightened crop's rectangle is measured on the rotated frame: not applied.
|
||||
usable &= v[4] == 0.0 && top < bottom && left < right;
|
||||
if !usable {
|
||||
let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some());
|
||||
report.ignored.extend(present.map(|(k, _)| k.to_string()));
|
||||
return;
|
||||
}
|
||||
if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] {
|
||||
report.crop = Some(Crop { top, left, bottom, right });
|
||||
report.applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn number(s: &str) -> Option<f32> {
|
||||
@@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) {
|
||||
if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
|
||||
fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) {
|
||||
if name == "HasCrop" {
|
||||
crop.has = Some(value.to_string());
|
||||
} else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) {
|
||||
if let Some(slot) = crop.values.get_mut(i) {
|
||||
*slot = Some(value.to_string());
|
||||
}
|
||||
} else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) {
|
||||
match number(value) {
|
||||
Some(v) => {
|
||||
*field(params) = v.clamp(*lo, *hi);
|
||||
@@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
|
||||
let mut next = params.clone();
|
||||
let mut report = LoadReport::default();
|
||||
let mut found = false;
|
||||
let mut crop_fields = CropFields::default();
|
||||
for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") {
|
||||
for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) {
|
||||
found = true;
|
||||
apply(&mut next, &mut report, a.name(), a.value());
|
||||
apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value());
|
||||
}
|
||||
for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) {
|
||||
found = true;
|
||||
@@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
|
||||
report.ignored.push(name.to_string());
|
||||
}
|
||||
} else {
|
||||
apply(&mut next, &mut report, name, c.text().unwrap_or(""));
|
||||
apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err("no Camera Raw settings in the file".into());
|
||||
}
|
||||
crop(&crop_fields, &mut report);
|
||||
*params = next;
|
||||
Ok(report)
|
||||
}
|
||||
@@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool {
|
||||
|
||||
/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads.
|
||||
pub fn save(params: &CameraRaw) -> String {
|
||||
save_with_crop(params, None)
|
||||
}
|
||||
|
||||
/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with.
|
||||
pub fn save_with_crop(params: &CameraRaw, crop: Option<Crop>) -> String {
|
||||
let mut p = params.clone();
|
||||
let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" };
|
||||
let mut out = String::from(
|
||||
@@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String {
|
||||
}
|
||||
let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n);
|
||||
out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style)));
|
||||
if let Some(c) = crop {
|
||||
out.push_str(&format!(
|
||||
" crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n",
|
||||
c.top, c.left, c.bottom, c.right
|
||||
));
|
||||
}
|
||||
out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n");
|
||||
for (name, field) in CURVES {
|
||||
let points = field(&mut p);
|
||||
@@ -433,7 +556,7 @@ mod tests {
|
||||
<crs:Clarity2012>abc</crs:Clarity2012></rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let r = load(&mut p, xmp).unwrap();
|
||||
assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0));
|
||||
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] });
|
||||
assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -479,4 +602,111 @@ mod tests {
|
||||
assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other));
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file with the `crs:` attributes `attrs`.
|
||||
fn with(attrs: &str) -> String {
|
||||
format!(
|
||||
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:RawFileName=\"IMG_0004.CR2\" {attrs}/></rdf:RDF></x:xmpmeta>"
|
||||
)
|
||||
}
|
||||
|
||||
fn report(attrs: &str) -> LoadReport {
|
||||
load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name
|
||||
/// as unsupported; the crop is read now, and the file name is bookkeeping.
|
||||
#[test]
|
||||
fn reads_the_crop_and_not_the_file_name() {
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#);
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 }));
|
||||
assert_eq!(r.applied, 1);
|
||||
// Element form, and the keys spread over two descriptions, read the same.
|
||||
let xmp = format!(
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description xmlns:crs=\"{CRS_NS}\"><crs:HasCrop>True</crs:HasCrop><crs:CropBottom>0.5</crs:CropBottom></rdf:Description><rdf:Description xmlns:crs=\"{CRS_NS}\" crs:CropRight=\"0.5\"/></rdf:RDF>"
|
||||
);
|
||||
let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap();
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's");
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_crop_without_has_crop_true() {
|
||||
let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#;
|
||||
for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] {
|
||||
let r = report(&format!("{has} {keys}"));
|
||||
assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}");
|
||||
}
|
||||
// The whole frame is no crop, and nothing to report.
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#);
|
||||
assert_eq!((r.crop, r.ignored.len()), (None, 0));
|
||||
let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#);
|
||||
assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()]));
|
||||
}
|
||||
|
||||
/// Hostile and straightened crops never panic: out-of-range fractions are clamped to the
|
||||
/// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are
|
||||
/// named.
|
||||
#[test]
|
||||
fn unusable_crops_are_reported_not_applied() {
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#);
|
||||
assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 }));
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::<Vec<_>>();
|
||||
for bad in [
|
||||
r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#,
|
||||
r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#,
|
||||
] {
|
||||
let r = report(&format!(r#"crs:HasCrop="True" {bad}"#));
|
||||
assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}");
|
||||
}
|
||||
let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#);
|
||||
assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has");
|
||||
}
|
||||
|
||||
/// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`).
|
||||
#[test]
|
||||
fn crops_follow_the_orientation() {
|
||||
let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 };
|
||||
let want = [
|
||||
(1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
|
||||
(2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }),
|
||||
(3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }),
|
||||
(4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }),
|
||||
(5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }),
|
||||
(6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }),
|
||||
(7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }),
|
||||
(8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }),
|
||||
(0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }),
|
||||
];
|
||||
for (o, w) in want {
|
||||
let got = c.oriented(o);
|
||||
let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6);
|
||||
assert!(close, "orientation {o}: {got:?}");
|
||||
}
|
||||
assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12)));
|
||||
assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel");
|
||||
assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame");
|
||||
assert_eq!(c.pixels(0, 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_with_crop_round_trips() {
|
||||
let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() };
|
||||
let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 };
|
||||
let text = save_with_crop(&p, Some(crop));
|
||||
assert!(is_photocraft_xmp(text.as_bytes()));
|
||||
let mut back = CameraRaw::default();
|
||||
let r = load(&mut back, text.as_bytes()).unwrap();
|
||||
assert_eq!((back, r.crop), (p.clone(), Some(crop)));
|
||||
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
|
||||
assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None);
|
||||
}
|
||||
}
|
||||
+102
-6
@@ -65,6 +65,9 @@ pub use ora::losses as ora_losses;
|
||||
pub mod pattern_map;
|
||||
mod pcraft_cache;
|
||||
pub mod pdf;
|
||||
#[cfg(all(feature = "pdf-editable", not(target_arch = "wasm32")))]
|
||||
mod pdf_editable;
|
||||
mod pdf_import;
|
||||
mod pdn;
|
||||
pub use pcraft_cache::PcraftExportCache;
|
||||
pub mod pixels;
|
||||
@@ -513,8 +516,8 @@ pub enum ImportPreflight {
|
||||
/// A flat raster decoded into one image, bounded by
|
||||
/// [`ImportOptions::flat_max_pixel_bytes`].
|
||||
Flat,
|
||||
/// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw and `.pcraft` files: this build has
|
||||
/// no header-only estimate of their pixel memory before decoding.
|
||||
/// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw, PDF and `.pcraft` files: this
|
||||
/// build has no header-only estimate of their pixel memory before decoding.
|
||||
Unbounded,
|
||||
}
|
||||
|
||||
@@ -555,6 +558,11 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight {
|
||||
if is_psd_input(name, bytes) {
|
||||
return ImportPreflight::Psd;
|
||||
}
|
||||
// PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this
|
||||
// importer too; a recognized raster signature wins over a misleading `.pdf` suffix.
|
||||
if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) {
|
||||
return ImportPreflight::Unbounded;
|
||||
}
|
||||
if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat }
|
||||
}
|
||||
|
||||
@@ -601,6 +609,18 @@ pub struct ImportOptions {
|
||||
/// Decoded-pixel allowance for a flat raster (the buffer a codec decodes into). May lower
|
||||
/// the codec's default cap; 0 uses it. PSD/PSB and layered TIFF use `psd_max_pixel_bytes`.
|
||||
pub flat_max_pixel_bytes: u64,
|
||||
/// Resolution (dots per inch) PDF pages are rendered at on import; the document keeps it so
|
||||
/// the page's print size is preserved. Defaults to 300.
|
||||
pub pdf_dpi: f32,
|
||||
/// The 1-based, inclusive page range to import; `None` imports every page (up to the cap).
|
||||
pub pdf_pages: Option<(u32, u32)>,
|
||||
/// Import PDF painted paths and embedded-font text as editable [`photocraft_doc::ShapeLayer`]s
|
||||
/// and text layers instead of a page raster (experimental). Images, complex clips/masks,
|
||||
/// patterns, non-normal blends and unembedded/Type 3 text make that page fall back to the
|
||||
/// raster importer with a warning. Text that uses fonts embedded in the PDF may change font
|
||||
/// when edited or reopened, because the document format does not carry the font bytes. Off by
|
||||
/// default.
|
||||
pub pdf_editable: bool,
|
||||
/// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget.
|
||||
pub psd_high_memory_approved: bool,
|
||||
/// Optional estimate limit for high-memory approval. When set, approval only applies if
|
||||
@@ -615,6 +635,9 @@ impl Default for ImportOptions {
|
||||
demosaic: raw::Demosaic::default(),
|
||||
psd_max_pixel_bytes: 0,
|
||||
flat_max_pixel_bytes: 0,
|
||||
pdf_dpi: 300.0,
|
||||
pdf_pages: None,
|
||||
pdf_editable: false,
|
||||
psd_high_memory_approved: false,
|
||||
psd_approved_estimate: None,
|
||||
}
|
||||
@@ -975,6 +998,16 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route {
|
||||
/// Dispatches format-specific importers and propagates a structured PSD damage
|
||||
/// flag alongside the ordinary best-effort import result.
|
||||
fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result<ImportResult, IoError> {
|
||||
// SVG content wins over a misleading `.pdf` suffix, matching the preflight (which checks SVG
|
||||
// before PDF).
|
||||
if is_svg_input(name, bytes) {
|
||||
return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth);
|
||||
}
|
||||
// PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this
|
||||
// importer too; a recognized raster signature wins over a misleading `.pdf` suffix.
|
||||
if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) {
|
||||
return pdf_import::import(name, bytes, opts, ctl);
|
||||
}
|
||||
match route(name, bytes) {
|
||||
Route::Pcraft => {
|
||||
let document = photocraft_format::load_from_bytes(bytes)?;
|
||||
@@ -999,10 +1032,28 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o
|
||||
pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool {
|
||||
// Keep the SVG extension as a fallback for malformed files so the SVG parser can return an
|
||||
// actionable SVG error. A recognized raster signature still wins over a misleading `.svg`
|
||||
// suffix (for example, a WebP downloaded as Firefox_logo.svg).
|
||||
// suffix (for example, a WebP downloaded as Firefox_logo.svg), and so does every other
|
||||
// format's signature: a PDF named `.svg` must reach the PDF importer.
|
||||
svg::is_svg(bytes)
|
||||
|| (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none())
|
||||
|| (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b]))
|
||||
|| (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none() && !has_format_signature(name, bytes))
|
||||
|| (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b]) && !has_format_signature(name, bytes))
|
||||
}
|
||||
|
||||
/// A non-raster format's signature or declared extension, checked before the `.svg` suffix
|
||||
/// fallback so a misnamed file keeps its own importer.
|
||||
fn has_format_signature(name: &str, bytes: &[u8]) -> bool {
|
||||
is_psd(bytes)
|
||||
|| pdf_import::is_pdf(bytes)
|
||||
|| is_psd_input(name, bytes)
|
||||
|| raw::is_raw(bytes)
|
||||
|| affinity::is_affinity(bytes)
|
||||
|| ora::is_ora(bytes)
|
||||
|| kra::is_kra(bytes)
|
||||
|| xcf::is_xcf(bytes)
|
||||
|| has_extension(name, "pdn")
|
||||
|| bytes.starts_with(b"PDN3")
|
||||
|| photocraft_format::is_pcraft(bytes)
|
||||
|| has_extension(name, photocraft_format::EXTENSION)
|
||||
}
|
||||
|
||||
fn extension(name_or_ext: &str) -> String {
|
||||
@@ -1154,7 +1205,7 @@ fn export_stages_with_renderer(
|
||||
return Ok(res.into());
|
||||
}
|
||||
if ext == "pdf" {
|
||||
let mut res = pdf::export(doc)?;
|
||||
let mut res = pdf::export(doc, ctl)?;
|
||||
if let Some(r) = renderer {
|
||||
res.warnings.push("PDF export uses CPU compositor; GPU rendering applies to flat formats".into());
|
||||
res.warnings.extend(r.drain_diagnostics());
|
||||
@@ -1570,4 +1621,49 @@ mod tests {
|
||||
assert_eq!(d.bounds().height(), 8, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF named `.svg` (a save-as mistake) must reach the PDF importer, not the SVG parser;
|
||||
/// SVG content still wins over a `.pdf` suffix.
|
||||
#[test]
|
||||
fn a_pdf_named_svg_stays_a_pdf() {
|
||||
let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
||||
assert!(!is_svg_input("drawing.svg", pdf));
|
||||
assert!(!is_svg_input("drawing.svgz", pdf));
|
||||
assert!(is_svg_input("drawing.svg", b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"));
|
||||
assert_eq!(crate::route("drawing.svg", pdf), crate::Route::Flat);
|
||||
}
|
||||
|
||||
/// `.ai` files that start with `%PDF-` are PDFs: both preflight and dispatch send them to the
|
||||
/// PDF importer rather than the flat decoder.
|
||||
#[test]
|
||||
fn an_ai_with_pdf_content_takes_the_pdf_importer() {
|
||||
use lopdf::{Document as PdfDocument, Object, Stream, dictionary};
|
||||
let mut pdf = PdfDocument::with_version("1.5");
|
||||
let pages_id = pdf.new_object_id();
|
||||
let content = b"0 0 1 rg 10 10 20 20 re f";
|
||||
let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec()));
|
||||
let page_id = pdf.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(100.0), Object::Real(100.0)]),
|
||||
"Contents" => content_id,
|
||||
"Resources" => dictionary! {},
|
||||
});
|
||||
pdf.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
|
||||
"Count" => 1,
|
||||
}),
|
||||
);
|
||||
let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||
pdf.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
pdf.save_to(&mut bytes).expect("writing the .ai fixture");
|
||||
assert!(crate::pdf_import::is_pdf(&bytes));
|
||||
assert_eq!(import_preflight("illustration.ai", &bytes), ImportPreflight::Unbounded);
|
||||
let result = import("illustration.ai", &bytes).expect("an .ai starting with %PDF- takes the PDF importer");
|
||||
assert_eq!(result.document.artboards().len(), 1);
|
||||
}
|
||||
}
|
||||
+207
-65
@@ -44,6 +44,8 @@ pub struct PrintPage {
|
||||
pub marks: Marks,
|
||||
pub description: Option<String>,
|
||||
pub label: Option<String>,
|
||||
/// The page's name in the PDF `/PageLabels` tree (Unicode-capable; not drawn on the page).
|
||||
pub page_label: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn pdf_string(s: &str) -> String {
|
||||
@@ -62,6 +64,16 @@ pub(super) fn pdf_string(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// A PDF text string with UTF-16BE hex (the Unicode-capable form, byte-order mark included).
|
||||
fn pdf_unicode_string(s: &str) -> String {
|
||||
let mut o = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
o.push_str(&format!("{u:04X}"));
|
||||
}
|
||||
o.push('>');
|
||||
o
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Vec<u8> {
|
||||
// Fast: print PDFs are transient and large. Level 3, not 1: zlib-rs level 1 writes static
|
||||
// Huffman blocks only (13-30% bigger on photos); level 3 is the lowest that is never bigger
|
||||
@@ -109,10 +121,20 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String {
|
||||
|
||||
/// A one-page PDF.
|
||||
pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
print_pdf_with_vectors(page, "", &selection::SelectionFont::default())
|
||||
print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default(), &photocraft_raster::Interrupt::NONE).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec<u8> {
|
||||
/// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`.
|
||||
fn print_pdf_pages(
|
||||
pages: &[PrintPage],
|
||||
vectors: &[String],
|
||||
selection_font: &selection::SelectionFont,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> std::result::Result<Vec<u8>, IoError> {
|
||||
if ctl.check().is_err() {
|
||||
return Err(IoError::Cancelled);
|
||||
}
|
||||
let Some(first) = pages.first() else { return Ok(Vec::new()) };
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
|
||||
@@ -121,72 +143,112 @@ fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &sele
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
let img = &page.image;
|
||||
let (pw, ph) = page.paper;
|
||||
let (x, y, w, h) = page.rect;
|
||||
let selection_base = if img.icc.is_some() { 8 } else { 7 };
|
||||
// 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional).
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>"
|
||||
let n = pages.len();
|
||||
// A label tree is written only for a non-empty name: an all-empty tree would be `/Nums []`,
|
||||
// and filtering the first empty name away would start the tree at page index 1, not 0.
|
||||
let labels_on = pages.iter().any(|p| p.page_label.as_deref().is_some_and(|label| !label.is_empty()));
|
||||
// 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection.
|
||||
let font_obj = 3 + n * 3;
|
||||
let icc = first.image.icc.as_ref();
|
||||
let icc_obj = icc.map(|_| font_obj + 1);
|
||||
let selection_base = font_obj + if icc.is_some() { 2 } else { 1 };
|
||||
let selection_objects = selection_font.objects(selection_base);
|
||||
let labels_obj = labels_on.then_some((selection_base + selection_objects.len()) as u32);
|
||||
obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{} >>", labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default()).as_bytes());
|
||||
let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
|
||||
obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes());
|
||||
for (i, page) in pages.iter().enumerate() {
|
||||
let page_obj = 3 + i * 3;
|
||||
let content_obj = page_obj + 1;
|
||||
let image_obj = page_obj + 2;
|
||||
let (pw, ph) = page.paper;
|
||||
let (x, y, w, h) = page.rect;
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 {image_obj} 0 R >> /Font << /F1 {font_obj} 0 R /FSelect {selection_base} 0 R >> >> /Contents {content_obj} 0 R >>"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
|
||||
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
|
||||
if let Some(v) = vectors.get(i) {
|
||||
content.push_str(v);
|
||||
}
|
||||
content.push_str(&marks_ops(page.marks, page.rect));
|
||||
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
|
||||
let pad = if any_marks { 30.0 } else { 6.0 };
|
||||
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
|
||||
}
|
||||
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
|
||||
}
|
||||
let c = deflate(content.as_bytes());
|
||||
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
|
||||
body.extend_from_slice(&c);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
let img = &page.image;
|
||||
let device = match img.channels {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
let cs = match icc_obj {
|
||||
Some(obj) => format!("[/ICCBased {obj} 0 R]"),
|
||||
None => device.to_string(),
|
||||
};
|
||||
let z = deflate(&img.data);
|
||||
let mut body = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
z.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
|
||||
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
|
||||
content.push_str(vectors);
|
||||
content.push_str(&marks_ops(page.marks, page.rect));
|
||||
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
|
||||
let pad = if any_marks { 30.0 } else { 6.0 };
|
||||
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
|
||||
}
|
||||
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
|
||||
}
|
||||
let c = deflate(content.as_bytes());
|
||||
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
|
||||
body.extend_from_slice(&c);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
let device = match img.channels {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() };
|
||||
let z = deflate(&img.data);
|
||||
let mut body = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
z.len()
|
||||
)
|
||||
.into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
||||
if let Some(icc) = &img.icc {
|
||||
let z = deflate(icc);
|
||||
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes();
|
||||
.into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
for body in selection_font.objects(selection_base) {
|
||||
obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
||||
if let Some(icc) = icc {
|
||||
let device = match first.image.channels {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
let z = deflate(icc);
|
||||
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", first.image.channels, z.len()).into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
for body in &selection_objects {
|
||||
obj(&mut out, body);
|
||||
}
|
||||
if labels_obj.is_some() {
|
||||
// Every page gets an entry: a number-tree range runs until the next key, so omitting an
|
||||
// empty name would make that page inherit the previous page's label. An empty
|
||||
// dictionary gives the page the default (decimal) label instead.
|
||||
let nums = pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| match p.page_label.as_deref().filter(|label| !label.is_empty()) {
|
||||
Some(label) => format!("{i} << /P {} >>", pdf_unicode_string(label)),
|
||||
None => format!("{i} << >>"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
obj(&mut out, format!("<< /Nums [{nums}] >>").as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
|
||||
for o in &offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The vector operators for the contiguous visible top prefix of `doc`, top to bottom, with the
|
||||
@@ -261,10 +323,17 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio
|
||||
(layers, selection_font)
|
||||
}
|
||||
|
||||
/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size
|
||||
/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the
|
||||
/// warning says so — and the embedded profile travels with the image.
|
||||
pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
/// Pixels in `rect`, saturating on overflow.
|
||||
fn rect_pixels(rect: photocraft_geom::Rect) -> u64 {
|
||||
u64::from(rect.width()).saturating_mul(u64::from(rect.height()))
|
||||
}
|
||||
|
||||
/// File › Save As / Save a Copy › PDF (#3401): the flattened document at its print size
|
||||
/// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one
|
||||
/// page for the whole document. Layers and transparency are dropped — the warning says so — and
|
||||
/// the embedded profile travels with the images.
|
||||
pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result::Result<ExportResult, IoError> {
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0));
|
||||
if w == 0 || h == 0 {
|
||||
return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into()));
|
||||
@@ -277,6 +346,82 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
};
|
||||
let fmt = doc.pixel_format();
|
||||
let n = fmt.channels().max(1);
|
||||
let dpi = f64::from(doc.resolution_dpi.max(1.0));
|
||||
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let icc = doc.icc_profile.as_ref().map(|p| p.to_vec());
|
||||
let artboards = doc.artboards();
|
||||
if !artboards.is_empty() {
|
||||
// Every page's encoded pixels are kept until the PDF is assembled; bound that peak.
|
||||
const MAX_PDF_EXPORT_BYTES: u64 = 1 << 30;
|
||||
let page_pixels: u64 = artboards.iter().fold(0u64, |bytes, (_, _, a)| bytes.saturating_add(rect_pixels(a.rect)));
|
||||
let retained = page_pixels.saturating_mul(device as u64);
|
||||
// One page renders at a time: its f32 composite surface plus the region read back from
|
||||
// it, both `n` channels at 4 bytes per pixel. The whole-document canvas is never held.
|
||||
let largest = artboards.iter().map(|(_, _, a)| rect_pixels(a.rect)).max().unwrap_or(0);
|
||||
let temporary = largest.saturating_mul(n as u64).saturating_mul(4).saturating_mul(2);
|
||||
let estimate = retained.saturating_add(temporary);
|
||||
if estimate > MAX_PDF_EXPORT_BYTES {
|
||||
return Err(IoError::Unsupported(format!(
|
||||
"PDF export would need about {} MB for {} artboards; export them in smaller batches",
|
||||
estimate >> 20,
|
||||
artboards.len()
|
||||
)));
|
||||
}
|
||||
// One document shell is reused for every page. Each page renders a document holding only
|
||||
// its artboard group (the same isolation as `artboard_document`), so an overlapping board
|
||||
// cannot paint on another board's page. Layer pixels are shared with `doc`; this one
|
||||
// clone and the per-page group clone hold no new pixel buffers, but they are outside the
|
||||
// 1 GiB estimate because they are not retained page data.
|
||||
let mut one = doc.clone();
|
||||
let mut pages = Vec::with_capacity(artboards.len());
|
||||
for (id, name, artboard) in &artboards {
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
let rect = artboard.rect;
|
||||
let (aw, ah) = (rect.width(), rect.height());
|
||||
if aw == 0 || ah == 0 {
|
||||
continue;
|
||||
}
|
||||
// A hidden artboard still gets its page, rendered from its own content rather than
|
||||
// left blank by the group's visibility.
|
||||
let Some(layer) = doc.layer(*id) else { continue };
|
||||
let mut group = layer.clone();
|
||||
group.visible = true;
|
||||
one.layers.clear();
|
||||
one.layers.push(group);
|
||||
// Render only this artboard's rect; the surface never holds the whole document.
|
||||
let surface = photocraft_compose::composite_to_surface(&one, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None);
|
||||
let values = surface.read_region(rect);
|
||||
let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device));
|
||||
for px in values.chunks(n) {
|
||||
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
for v in px.iter().take(device) {
|
||||
data.push(to8(v * a + paper * (1.0 - a)));
|
||||
}
|
||||
}
|
||||
let image = PrintImage { width: aw, height: ah, channels: device, data, icc: icc.clone() };
|
||||
let (pw, ph) = (f64::from(aw) * 72.0 / dpi, f64::from(ah) * 72.0 / dpi);
|
||||
pages.push(PrintPage {
|
||||
paper: (pw, ph),
|
||||
image,
|
||||
rect: (0.0, 0.0, pw, ph),
|
||||
marks: Marks::default(),
|
||||
description: None,
|
||||
label: None,
|
||||
page_label: Some((*name).to_string()),
|
||||
});
|
||||
}
|
||||
if pages.is_empty() {
|
||||
return Err(IoError::Unsupported("PDF: the document's artboards have no area".into()));
|
||||
}
|
||||
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
|
||||
warnings.push(format!("PDF exported {} artboards as {} pages; vector/text layers on artboards are rasterised", artboards.len(), pages.len()));
|
||||
if !doc.metadata.text.is_empty() {
|
||||
warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into());
|
||||
}
|
||||
let vectors = vec![String::new(); pages.len()];
|
||||
return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default(), ctl)?, warnings });
|
||||
}
|
||||
let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
|
||||
// Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing
|
||||
// compositing. Stop at the first unsupported visible layer, since it may depend on the
|
||||
@@ -290,10 +435,8 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
}
|
||||
let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None);
|
||||
let values = surface.read_region(canvas);
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
// Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink
|
||||
// in CMYK (0 = paper).
|
||||
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
|
||||
let mut data = Vec::with_capacity((w * h).saturating_mul(device));
|
||||
for px in values.chunks(n) {
|
||||
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
@@ -301,10 +444,9 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
data.push(to8(v * a + paper * (1.0 - a)));
|
||||
}
|
||||
}
|
||||
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) };
|
||||
let dpi = f64::from(doc.resolution_dpi.max(1.0));
|
||||
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: icc.clone() };
|
||||
let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi);
|
||||
let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None };
|
||||
let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None, page_label: None };
|
||||
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
|
||||
if !doc.metadata.text.is_empty() {
|
||||
warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into());
|
||||
@@ -315,7 +457,7 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
vectors.push_str(ops);
|
||||
}
|
||||
vectors.push_str("Q\n");
|
||||
Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings })
|
||||
Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font, ctl)?, warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+170
-18
@@ -1,10 +1,11 @@
|
||||
//! PDF export: structure, pixel oracle, colour modes and refusal paths.
|
||||
|
||||
use super::*;
|
||||
use lopdf::{Object, StringFormat};
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_doc::{Layer, LayerContent, Size};
|
||||
use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
|
||||
fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
@@ -68,7 +69,7 @@ fn check_structure(pdf: &[u8]) {
|
||||
#[test]
|
||||
fn a_flat_rgb_document_saves_as_one_page_pdf() {
|
||||
let d = doc(ColorMode::Rgb);
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
let pdf = &out.bytes;
|
||||
check_structure(pdf);
|
||||
assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in");
|
||||
@@ -85,7 +86,7 @@ fn layers_composite_over_white_before_the_page() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5]))));
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
let img = stream_after(&out.bytes, "/Subtype /Image");
|
||||
// Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white.
|
||||
assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]);
|
||||
@@ -102,7 +103,7 @@ fn topmost_solid_shape_is_exported_as_a_vector_path() {
|
||||
..Default::default()
|
||||
};
|
||||
d.layers.push(Layer::new("Shape", LayerContent::Shape(shape)));
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
@@ -128,7 +129,7 @@ fn topmost_text_layer_is_exported_with_selectable_text_and_vector_outlines() {
|
||||
let baseline = layout.lines.get(cluster.line).expect("a line").baseline;
|
||||
let origin = text.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(baseline)));
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(text)));
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
@@ -159,7 +160,7 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() {
|
||||
d.layers.push(Layer::new("Shape", LayerContent::Shape(shape)));
|
||||
let text = TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() };
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(text)));
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
@@ -173,10 +174,10 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() {
|
||||
|
||||
#[test]
|
||||
fn greyscale_and_cmyk_pages_keep_their_channels() {
|
||||
let g = export(&doc(ColorMode::Grayscale)).unwrap();
|
||||
let g = export(&doc(ColorMode::Grayscale), &Interrupt::NONE).unwrap();
|
||||
assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray");
|
||||
assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel");
|
||||
let c = export(&doc(ColorMode::Cmyk)).unwrap();
|
||||
let c = export(&doc(ColorMode::Cmyk), &Interrupt::NONE).unwrap();
|
||||
assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK");
|
||||
assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page");
|
||||
}
|
||||
@@ -188,7 +189,7 @@ fn the_page_measures_at_the_document_resolution() {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))];
|
||||
d.resolution_dpi = 300.0;
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch");
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ fn the_page_measures_at_the_document_resolution() {
|
||||
fn an_embedded_profile_travels_as_iccbased() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
let out = export(&d).unwrap();
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile");
|
||||
assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive");
|
||||
}
|
||||
@@ -204,7 +205,7 @@ fn an_embedded_profile_travels_as_iccbased() {
|
||||
#[test]
|
||||
fn other_modes_are_refused_with_an_actionable_error() {
|
||||
let d = doc(ColorMode::Lab);
|
||||
let e = export(&d).unwrap_err().to_string();
|
||||
let e = export(&d, &Interrupt::NONE).unwrap_err().to_string();
|
||||
assert!(e.contains("RGB") && e.contains("Mode"), "{e}");
|
||||
assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err());
|
||||
}
|
||||
@@ -226,7 +227,7 @@ fn unicode_text_over_a_photo_extracts_through_a_pdf_reader() {
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), text);
|
||||
assert!(has(&pdf, "/Subtype /Image"));
|
||||
@@ -266,7 +267,7 @@ fn text_with_compositing_or_a_profile_stays_in_the_raster_backdrop() {
|
||||
_ => d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4])),
|
||||
}
|
||||
d.layers.push(layer);
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(!content.contains("/ActualText"), "case {case} must retain the raster composite");
|
||||
assert!(stream_after(&pdf, "/Subtype /Image").iter().any(|v| *v != 255), "case {case} must retain visible text");
|
||||
@@ -304,7 +305,7 @@ fn text_outside_the_vector_prefix_does_not_consume_the_selection_font() {
|
||||
"Top text",
|
||||
LayerContent::Text(TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "case {case}: only the top text is selectable");
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
@@ -355,7 +356,7 @@ fn a_text_layer_refused_at_emission_keeps_the_upper_vectors_and_stays_raster() {
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
check_structure(&pdf);
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF0048"), "the upper text stays vector:\n{content}");
|
||||
@@ -377,7 +378,7 @@ fn multiline_text_keeps_its_paragraph_breaks_in_the_selection() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(TextLayer { text: "A\nB".into(), size_pt: 12.0, ..Default::default() })));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "A\nB", "the paragraph break survives selection");
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
@@ -389,7 +390,158 @@ fn leading_newline_stays_in_the_first_selection_span() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(TextLayer { text: "\nA".into(), size_pt: 12.0, ..Default::default() })));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF000A0041>"), "the first span retains the leading newline:\n{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Each artboard becomes one labelled page, in document order.
|
||||
fn artboards_export_as_one_page_each() {
|
||||
use photocraft_doc::{Artboard, Group};
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let mut d = Document::new("pages.pdf", Size { width: 40, height: 30 }, ColorMode::Rgb, SampleType::F32);
|
||||
d.resolution_dpi = 72.0;
|
||||
for (rect, px) in [(Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]), (Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 1.0])] {
|
||||
let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &px)));
|
||||
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
d.layers.push(Layer::new("Page", LayerContent::Group(group)));
|
||||
}
|
||||
let out = export(&d, &Interrupt::NONE).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let parsed = lopdf::Document::load_mem(&out.bytes).unwrap();
|
||||
let pages = parsed.get_pages();
|
||||
assert_eq!(pages.len(), 2);
|
||||
for id in pages.values() {
|
||||
let page = parsed.get_dictionary(*id).unwrap();
|
||||
let media = page.get(b"MediaBox").unwrap().as_array().unwrap();
|
||||
let w = media[2].as_float().or_else(|_| media[2].as_i64().map(|v| v as f32)).unwrap();
|
||||
let h = media[3].as_float().or_else(|_| media[3].as_i64().map(|v| v as f32)).unwrap();
|
||||
assert!((w - 20.0).abs() < 0.01 && (h - 30.0).abs() < 0.01, "page {w}x{h}");
|
||||
}
|
||||
assert_eq!(out.bytes.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2);
|
||||
}
|
||||
|
||||
/// Side-by-side 2x2 artboards at 72 dpi, named by `names` (empty names are kept).
|
||||
fn artboard_doc(names: &[&str]) -> Document {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let width = (names.len() as u32).saturating_mul(2).max(2);
|
||||
let mut d = Document::new("labels.pdf", Size { width, height: 2 }, ColorMode::Rgb, SampleType::F32);
|
||||
d.resolution_dpi = 72.0;
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let x0 = (index as i32) * 2;
|
||||
let rect = Rect::new(x0, 0, x0 + 2, 2);
|
||||
let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &[0.5, 0.5, 0.5, 1.0])));
|
||||
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
d.layers.push(Layer::new(*name, LayerContent::Group(group)));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The decompressed samples of page `id`'s first image XObject (`/Im0`).
|
||||
fn page_image(pdf: &lopdf::Document, id: lopdf::ObjectId) -> Vec<u8> {
|
||||
let page = pdf.get_dictionary(id).expect("page dictionary");
|
||||
let resources = page.get(b"Resources").expect("page resources").as_dict().expect("resources dictionary");
|
||||
let xobjects = resources.get(b"XObject").expect("xobjects").as_dict().expect("xobject dictionary");
|
||||
let image = xobjects.get(b"Im0").expect("Im0").as_reference().expect("image reference");
|
||||
pdf.get_object(image).expect("image object").as_stream().expect("image stream").decompressed_content().expect("image samples")
|
||||
}
|
||||
|
||||
/// A named artboard gives every page a `/PageLabels` entry, and an empty name gets an empty
|
||||
/// label dictionary rather than inheriting the previous name.
|
||||
#[test]
|
||||
fn page_labels_name_every_page_and_an_empty_name_does_not_inherit() {
|
||||
let d = artboard_doc(&["A", "", "C"]);
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
check_structure(&pdf);
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
let labels = parsed
|
||||
.catalog()
|
||||
.unwrap()
|
||||
.get(b"PageLabels")
|
||||
.expect("a named artboard gives the PDF page labels")
|
||||
.as_reference()
|
||||
.expect("the label tree is an indirect object");
|
||||
let nums = parsed.get_dictionary(labels).unwrap().get(b"Nums").unwrap().as_array().unwrap();
|
||||
assert_eq!(nums.len(), 6, "one key and one label dictionary per page");
|
||||
for page in 0..3 {
|
||||
assert_eq!(nums[page * 2].as_i64().unwrap(), page as i64, "the tree starts at page index 0 and covers every page");
|
||||
}
|
||||
let label = |page: usize| nums[page * 2 + 1].as_dict().unwrap();
|
||||
assert_eq!(label(0).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x41], StringFormat::Hexadecimal), "page 1 is A");
|
||||
assert!(label(1).get(b"P").is_err(), "the empty name gets an empty dictionary, not A");
|
||||
assert_eq!(label(2).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x43], StringFormat::Hexadecimal), "page 3 is C");
|
||||
assert_eq!(parsed.get_pages().len(), 3);
|
||||
}
|
||||
|
||||
/// With no non-empty name there is no label tree at all: an empty `/Nums` would be invalid.
|
||||
#[test]
|
||||
fn an_all_empty_artboard_names_document_has_no_page_labels_tree() {
|
||||
let d = artboard_doc(&["", "", ""]);
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
check_structure(&pdf);
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert!(parsed.catalog().unwrap().get(b"PageLabels").is_err(), "no names means no /PageLabels");
|
||||
assert!(!has(&pdf, "/PageLabels"));
|
||||
assert_eq!(parsed.get_pages().len(), 3, "the pages themselves are still written");
|
||||
}
|
||||
|
||||
/// Two artboards at the same rectangle: each page must hold only its own board's pixels, not
|
||||
/// the other board composited over it.
|
||||
#[test]
|
||||
fn overlapping_artboards_export_only_their_own_pixels() {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let mut d = Document::new("overlap.pdf", Size { width: 2, height: 2 }, ColorMode::Rgb, SampleType::F32);
|
||||
d.resolution_dpi = 72.0;
|
||||
for (name, color) in [("Red", [1.0, 0.0, 0.0, 1.0]), ("Blue", [0.0, 0.0, 1.0, 1.0])] {
|
||||
let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, 0, 0, 2, 2, &color)));
|
||||
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(Rect::new(0, 0, 2, 2))), frame_stroke: None };
|
||||
d.layers.push(Layer::new(name, LayerContent::Group(group)));
|
||||
}
|
||||
let pdf = export(&d, &Interrupt::NONE).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
let pages = parsed.get_pages();
|
||||
let images = pages.values().map(|id| page_image(&parsed, *id)).collect::<Vec<_>>();
|
||||
assert_eq!(images.len(), 2);
|
||||
assert_eq!(images[0], [255u8, 0, 0].repeat(4), "the bottom board's page is red, not blue over red");
|
||||
assert_eq!(images[1], [0u8, 0, 255].repeat(4), "the top board's page is blue");
|
||||
}
|
||||
|
||||
/// A large, mostly empty canvas must not be charged as if its whole area were rendered.
|
||||
#[test]
|
||||
fn sparse_canvas_artboards_export_without_a_false_memory_limit() {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let mut d = Document::new("sparse.pdf", Size { width: 9000, height: 9000 }, ColorMode::Rgb, SampleType::F32);
|
||||
d.resolution_dpi = 72.0;
|
||||
for index in 0..2 {
|
||||
let x0 = index * 10;
|
||||
let rect = Rect::new(x0, 0, x0 + 2, 2);
|
||||
let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, 0, rect.x1, 2, &[1.0, 0.0, 0.0, 1.0])));
|
||||
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
d.layers.push(Layer::new("Page", LayerContent::Group(group)));
|
||||
}
|
||||
let out = export(&d, &Interrupt::NONE).expect("two 2x2 boards on a 9000x9000 canvas must fit the export budget");
|
||||
let parsed = lopdf::Document::load_mem(&out.bytes).unwrap();
|
||||
assert_eq!(parsed.get_pages().len(), 2);
|
||||
}
|
||||
|
||||
/// Seven US Letter boards at 300 dpi with the importer's gaps: the pages are retained as encoded
|
||||
/// data, but no whole-canvas buffer exists, so the old whole-canvas charge rejected this.
|
||||
#[test]
|
||||
fn seven_us_letter_artboards_export_without_a_false_memory_limit() {
|
||||
const PAGE_W: u32 = 2550;
|
||||
const PAGE_H: u32 = 3300;
|
||||
const GAP: u32 = 80;
|
||||
const PAGES: u32 = 7;
|
||||
let mut d = Document::new("letters.pdf", Size { width: PAGES * PAGE_W + (PAGES - 1) * GAP, height: PAGE_H }, ColorMode::Rgb, SampleType::F32);
|
||||
d.resolution_dpi = 300.0;
|
||||
for index in 0..PAGES {
|
||||
let x0 = (index * (PAGE_W + GAP)) as i32;
|
||||
let rect = Rect::new(x0, 0, x0 + PAGE_W as i32, PAGE_H as i32);
|
||||
let group = Group { children: Vec::new(), expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
d.layers.push(Layer::new(format!("Letter {}", index + 1), LayerContent::Group(group)));
|
||||
}
|
||||
let out = export(&d, &Interrupt::NONE).expect("seven US Letter pages at 300 dpi fit the export budget");
|
||||
let parsed = lopdf::Document::load_mem(&out.bytes).unwrap();
|
||||
assert_eq!(parsed.get_pages().len(), 7);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,403 @@
|
||||
//! PDF import: craft-pdf (a Rust PDF reader and renderer built on Hayro) rasterizes the
|
||||
//! selected pages, and each page becomes one artboard.
|
||||
//!
|
||||
//! Raster pages only: editable text and vector import would need craft-pdf's `interpret`
|
||||
//! device and is not part of this importer. The renderer's hard output bounds (8192 px per
|
||||
//! side and 64M px per page) and a document-wide strip budget are respected by lowering the
|
||||
//! effective DPI, so each page keeps its physical size: pixels / resolution * 72 pt.
|
||||
//!
|
||||
//! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded
|
||||
//! or shipped, and a malformed file is reported as an error rather than taking the process down.
|
||||
//!
|
||||
//! Pages are drawn with the interpreter's default annotation settings, so annotations and
|
||||
//! filled-in form fields are rendered; comments are not hidden.
|
||||
|
||||
use crate::{ImportOptions, ImportResult, IoError};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_pdf::interpret::InterpreterSettings;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_pdf::raster::{self, RenderCache};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Interrupt;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use photocraft_raster::Surface;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::Duration;
|
||||
|
||||
/// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer.
|
||||
pub fn is_pdf(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"%PDF-")
|
||||
}
|
||||
|
||||
/// PDF import is not available in the web build.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result<ImportResult, IoError> {
|
||||
Err(IoError::Unsupported("PDF import needs the desktop build".into()))
|
||||
}
|
||||
|
||||
/// Renders the selected PDF pages and opens each as one artboard.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result<ImportResult, IoError> {
|
||||
/// The most pages one import renders.
|
||||
const MAX_PAGES: usize = 16;
|
||||
/// Gap between the artboards, in document pixels.
|
||||
const GAP: u32 = 80;
|
||||
/// The document-wide raster budget across every imported page, in pixels.
|
||||
const MAX_UNION_PIXELS: u64 = 200_000_000;
|
||||
/// The default pixel-memory budget when the caller sets none: a zero
|
||||
/// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import.
|
||||
const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30;
|
||||
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 };
|
||||
let pdf = craft_pdf::open_slice_with_options(bytes, None, &craft_pdf::OpenOptions::default()).map_err(open_error)?;
|
||||
let total = pdf.pages().len();
|
||||
if total == 0 {
|
||||
return Err(IoError::Unsupported("PDF has no pages".into()));
|
||||
}
|
||||
let (first, last) = match opts.pdf_pages {
|
||||
Some((a, b)) => {
|
||||
let a = a.max(1);
|
||||
let b = if b == 0 { u32::MAX } else { b.max(a) };
|
||||
if u64::from(a) > total as u64 {
|
||||
return Err(IoError::Unsupported(format!("PDF page range starts at {a}, but the PDF has {total} pages")));
|
||||
}
|
||||
((a as usize).min(total), (b as usize).min(total))
|
||||
}
|
||||
None => (1, total),
|
||||
};
|
||||
let page_count = last - first + 1;
|
||||
let count = page_count.min(MAX_PAGES);
|
||||
|
||||
// Page sizes in points first: the effective DPI must fit the renderer's bounds without
|
||||
// changing any page's physical size.
|
||||
let mut sizes = Vec::with_capacity(count);
|
||||
for index in 0..count {
|
||||
let page = &pdf.pages()[first - 1 + index];
|
||||
let (width, height) = page.render_dimensions();
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return Err(IoError::Unsupported(format!("PDF page {} has no area", first + index)));
|
||||
}
|
||||
sizes.push((f64::from(width), f64::from(height)));
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
}
|
||||
let dpi = effective_dpi(requested, &sizes, u64::from(raster::MAX_SIDE), raster::MAX_PIXELS, MAX_UNION_PIXELS);
|
||||
|
||||
let mut doc_width: u64 = 0;
|
||||
let mut doc_height: u64 = 0;
|
||||
let mut retained: u64 = 0;
|
||||
for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() {
|
||||
let width = page_pixels(width_pt, dpi);
|
||||
let height = page_pixels(height_pt, dpi);
|
||||
doc_width = doc_width.saturating_add(u64::from(width)).saturating_add(if index + 1 < count { u64::from(GAP) } else { 0 });
|
||||
doc_height = doc_height.max(u64::from(height));
|
||||
let tiles_x = (u64::from(width)).div_ceil(256);
|
||||
let tiles_y = (u64::from(height)).div_ceil(256);
|
||||
retained = retained.saturating_add(tiles_x.saturating_mul(tiles_y).saturating_mul(256 * 256 * 4));
|
||||
}
|
||||
if doc_width == 0 || doc_height == 0 {
|
||||
return Err(IoError::Unsupported("PDF pages have no area".into()));
|
||||
}
|
||||
if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) {
|
||||
return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into()));
|
||||
}
|
||||
// Retained tiles plus the converting page's raster (4 B/px); the f32 conversion runs in
|
||||
// 256-row bands, so it adds only a constant-sized buffer rather than a second full page.
|
||||
let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0);
|
||||
let estimate = retained.saturating_add(max_page.saturating_mul(4));
|
||||
let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES };
|
||||
if estimate > budget {
|
||||
return Err(IoError::Unsupported(format!(
|
||||
"PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)",
|
||||
estimate >> 20,
|
||||
budget >> 20
|
||||
)));
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if first > 1 || last < total || count < page_count {
|
||||
let shown_last = first + count - 1;
|
||||
let reason = if count < page_count { " (the page limit is 16)" } else { "" };
|
||||
warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}"));
|
||||
}
|
||||
if dpi + 0.5 < requested {
|
||||
warnings.push(format!("PDF was rendered at {:.0} dpi to fit the import limits", dpi.max(1.0)));
|
||||
}
|
||||
|
||||
let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8);
|
||||
doc.resolution_dpi = dpi as f32;
|
||||
#[cfg(feature = "pdf-editable")]
|
||||
let mut editable_text = false;
|
||||
let mut x: u32 = 0;
|
||||
for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() {
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
let page_number = first + index;
|
||||
let page = &pdf.pages()[page_number - 1];
|
||||
let width = page_pixels(width_pt, dpi);
|
||||
let height = page_pixels(height_pt, dpi);
|
||||
// The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if
|
||||
// rounding on an edge moves one pixel past them.
|
||||
if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS {
|
||||
return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI")));
|
||||
}
|
||||
let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32);
|
||||
#[cfg(feature = "pdf-editable")]
|
||||
if opts.pdf_editable {
|
||||
match crate::pdf_editable::page(page, rect, ctl) {
|
||||
Ok(layers) => {
|
||||
if layers.iter().any(|layer| matches!(&layer.content, LayerContent::Text(_))) {
|
||||
editable_text = true;
|
||||
}
|
||||
let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group)));
|
||||
x = x.saturating_add(width).saturating_add(GAP);
|
||||
ctl.progress((index + 1) as f32 / count as f32);
|
||||
continue;
|
||||
}
|
||||
Err(crate::pdf_editable::EditableError::Cancelled) => return Err(IoError::Cancelled),
|
||||
Err(crate::pdf_editable::EditableError::Unsupported(reason)) => {
|
||||
warnings.push(format!("PDF page {page_number}: {reason}; the page was rasterised"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "pdf-editable"))]
|
||||
if opts.pdf_editable {
|
||||
warnings.push(format!("PDF page {page_number}: editable PDF import is not built into this binary; the page was rasterised"));
|
||||
}
|
||||
// A cache per page, deliberately: a shared one would keep decoded images and fonts alive
|
||||
// across up to 16 pages outside the pixel budget, and would hide an image-decode warning
|
||||
// on a later page after the page that first hit it.
|
||||
let cache = RenderCache::new();
|
||||
let options = raster::Options {
|
||||
scale: [(dpi / 72.0) as f32; 2],
|
||||
size: Some([width, height]),
|
||||
offset: [0.0, 0.0],
|
||||
// PDF pages composite onto white, as every PDF viewer and the previous importer did.
|
||||
// craft-pdf returns premultiplied RGBA8; an opaque background makes alpha 1, so the
|
||||
// bytes below can be written as straight alpha. A transparent background would need
|
||||
// un-premultiplying (dividing each colour by alpha) first.
|
||||
background: [255, 255, 255, 255],
|
||||
color_replacement: None,
|
||||
};
|
||||
let rendered = render_page(page, &cache, &options, ctl).map_err(|error| render_error(page_number, dpi, error))?;
|
||||
if rendered.diagnostics.image_decode_failed {
|
||||
warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing"));
|
||||
}
|
||||
// craft-pdf's hayro backend does not emit this flag yet (its README says so); keep the
|
||||
// warning ready so a backend that starts reporting font substitution needs no change here.
|
||||
if rendered.diagnostics.unsupported_font {
|
||||
warnings.push(format!("PDF page {page_number}: a font could not be used and was substituted, so text may look different"));
|
||||
}
|
||||
if rendered.diagnostics.content_truncated {
|
||||
warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)"));
|
||||
}
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
|
||||
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
||||
let bytes = rendered.bytes();
|
||||
// Convert and write in row bands: the f32 buffer stays bounded instead of a second
|
||||
// 16-bytes-per-pixel copy of the whole page, and cancellation is polled per band.
|
||||
const BAND_ROWS: usize = 256;
|
||||
let row_bytes = width as usize * 4;
|
||||
let band_bytes = row_bytes.saturating_mul(BAND_ROWS);
|
||||
let mut data: Vec<f32> = Vec::new();
|
||||
if data.try_reserve_exact(band_bytes).is_err() {
|
||||
return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available")));
|
||||
}
|
||||
for (band, rows) in bytes.chunks(band_bytes).enumerate() {
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
data.clear();
|
||||
data.extend(rows.iter().map(|&value| f32::from(value) / 255.0));
|
||||
let top = (band * BAND_ROWS) as i32;
|
||||
let band_height = (rows.len() / row_bytes) as u32;
|
||||
surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data);
|
||||
}
|
||||
let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface));
|
||||
let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group)));
|
||||
x = x.saturating_add(width).saturating_add(GAP);
|
||||
ctl.progress((index + 1) as f32 / count as f32);
|
||||
}
|
||||
#[cfg(feature = "pdf-editable")]
|
||||
if editable_text {
|
||||
warnings.push("PDF text uses fonts embedded in the PDF; it may change font when edited or reopened".into());
|
||||
}
|
||||
|
||||
Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
/// Renders one page, forwarding `ctl`'s cancellation to craft-pdf while it interprets content.
|
||||
///
|
||||
/// craft-pdf checks `InterpreterSettings::cancelled` between content operators, so a dense
|
||||
/// vector page (CAD, maps) can be stopped part-way through. `Interrupt` is a borrowed callback
|
||||
/// while the setting needs an owned flag, so a scoped watcher thread copies one into the other
|
||||
/// every few milliseconds until the render returns.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn render_page<'a>(
|
||||
page: &'a craft_pdf::syntax::page::Page<'a>,
|
||||
cache: &RenderCache<'a>,
|
||||
options: &raster::Options,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<raster::Raster, raster::Error> {
|
||||
if ctl.cancelled() {
|
||||
return Err(raster::Error::Cancelled);
|
||||
}
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() };
|
||||
std::thread::scope(|scope| {
|
||||
let flag = Arc::clone(&cancelled);
|
||||
let done = Arc::clone(&finished);
|
||||
let ctl = *ctl;
|
||||
scope.spawn(move || {
|
||||
while !done.load(Ordering::Relaxed) {
|
||||
if ctl.cancelled() {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
});
|
||||
let rendered = raster::render(page, cache, &settings, options);
|
||||
finished.store(true, Ordering::Relaxed);
|
||||
rendered
|
||||
})
|
||||
}
|
||||
|
||||
/// One page's raster width or height in pixels at `dpi`, at least one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn page_pixels(points: f64, dpi: f64) -> u32 {
|
||||
(points * dpi / 72.0).round().max(1.0) as u32
|
||||
}
|
||||
|
||||
/// The highest DPI not above `requested` that keeps every page inside the renderer's side and
|
||||
/// pixel bounds and the whole strip inside `max_union_pixels`. Rendering at that DPI keeps each
|
||||
/// page's physical size unchanged; unreachable bounds report 1 DPI and the render call then fails
|
||||
/// with a clear message.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn effective_dpi(requested: f64, sizes: &[(f64, f64)], max_side: u64, max_page_pixels: u64, max_union_pixels: u64) -> f64 {
|
||||
let mut dpi = requested.clamp(1.0, 2400.0);
|
||||
loop {
|
||||
let mut union = 0u64;
|
||||
let mut fits = true;
|
||||
for &(width_pt, height_pt) in sizes {
|
||||
let width = u64::from(page_pixels(width_pt, dpi));
|
||||
let height = u64::from(page_pixels(height_pt, dpi));
|
||||
if width > max_side || height > max_side || width.saturating_mul(height) > max_page_pixels {
|
||||
fits = false;
|
||||
break;
|
||||
}
|
||||
union = union.saturating_add(width.saturating_mul(height));
|
||||
}
|
||||
if fits && union <= max_union_pixels {
|
||||
return dpi;
|
||||
}
|
||||
if dpi <= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
// The strip's gaps contribute a constant width, so a geometric step converges without
|
||||
// needing a closed form per page.
|
||||
dpi = (dpi * 0.9).max(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a craft-pdf open failure to the importer's error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_error(error: craft_pdf::OpenError) -> IoError {
|
||||
use craft_pdf::OpenError;
|
||||
match error {
|
||||
OpenError::InputLimit { limit } => IoError::Unsupported(format!("PDF is larger than the {limit}-byte input limit")),
|
||||
OpenError::Allocation => IoError::Unsupported("PDF needs more memory than is available".into()),
|
||||
OpenError::BackendPanic => IoError::Unsupported("PDF reader failed on this file; it may be damaged".into()),
|
||||
OpenError::Load(error) => IoError::Unsupported(format!("PDF: {error:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a craft-pdf render failure to the importer's error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError {
|
||||
use raster::Error;
|
||||
match error {
|
||||
Error::Cancelled => IoError::Cancelled,
|
||||
Error::OutputLimit => IoError::Unsupported(format!("PDF page {page} is too large at {dpi:.0} dpi; lower the import DPI")),
|
||||
Error::InvalidPage => IoError::Unsupported(format!("PDF page {page} has an empty or invalid page box")),
|
||||
Error::Allocation => IoError::Unsupported(format!("PDF page {page} needs more memory than is available")),
|
||||
Error::BackendPanic => IoError::Unsupported(format!("PDF renderer failed on page {page}; it may be damaged")),
|
||||
Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use lopdf::{Document as PdfDocument, Object, Stream, dictionary};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A one-page PDF whose content stream is `operators` short strokes: rendering costs time
|
||||
/// per operator, not per pixel.
|
||||
fn dense_operator_pdf(operators: usize) -> Result<Vec<u8>, lopdf::Error> {
|
||||
let mut pdf = PdfDocument::with_version("1.5");
|
||||
let pages_id = pdf.new_object_id();
|
||||
let mut content = String::with_capacity(operators.saturating_mul(24));
|
||||
content.push_str("0 0 1 RG 0.5 w\n");
|
||||
for i in 0..operators {
|
||||
let x = (i % 200) as f32 * 0.5;
|
||||
let y = ((i / 200) % 100) as f32 * 0.5;
|
||||
content.push_str(&format!("{x:.1} {y:.1} m {:.1} {y:.1} l S\n", x + 0.4));
|
||||
}
|
||||
let content_id = pdf.add_object(Stream::new(dictionary! {}, content.into_bytes()));
|
||||
let page_id = pdf.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]),
|
||||
"Contents" => content_id,
|
||||
"Resources" => dictionary! {},
|
||||
});
|
||||
pdf.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
|
||||
"Count" => 1,
|
||||
}),
|
||||
);
|
||||
let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||
pdf.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
pdf.save_to(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Hundreds of thousands of path operators take far longer than the watcher's polling
|
||||
/// interval, so the interrupt lands mid-render and the page reports Cancelled. A million
|
||||
/// strokes leaves a wide margin even on a fast, optimised test build.
|
||||
#[test]
|
||||
fn a_dense_page_stops_when_the_interrupt_fires_mid_render() {
|
||||
let pdf = dense_operator_pdf(1_000_000).expect("writing the dense PDF");
|
||||
let document = craft_pdf::open_slice_with_options(&pdf, None, &craft_pdf::OpenOptions::default()).expect("the dense PDF opens");
|
||||
let cache = RenderCache::new();
|
||||
let options =
|
||||
raster::Options { scale: [1.0, 1.0], size: Some([200, 100]), offset: [0.0, 0.0], background: [255, 255, 255, 255], color_replacement: None };
|
||||
let polls = AtomicUsize::new(0);
|
||||
// `render_page` checks once before spawning; the watcher then polls every 5 ms. Ten
|
||||
// polls put the cancellation about 45 ms into a render that takes much longer.
|
||||
let cancel = || polls.fetch_add(1, Ordering::SeqCst) >= 9;
|
||||
let ctl = Interrupt::cancel_only(&cancel);
|
||||
let error = match render_page(&document.pages()[0], &cache, &options, &ctl) {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("the dense page rendered to completion before the interrupt fired"),
|
||||
};
|
||||
assert!(matches!(render_error(1, 72.0, error), IoError::Cancelled), "the render error maps to Cancelled");
|
||||
assert!(polls.load(Ordering::SeqCst) >= 10, "the watcher must keep polling the interrupt");
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) ->
|
||||
|
||||
/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze,
|
||||
/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the
|
||||
/// developed RGB pixels, and the demosaic method.
|
||||
/// developed RGB pixels, the demosaic method, and the crop.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RawTuning {
|
||||
/// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`.
|
||||
@@ -71,6 +71,9 @@ pub struct RawTuning {
|
||||
pub texture: f32,
|
||||
/// −100…100, as `CameraRaw::dehaze`.
|
||||
pub dehaze: f32,
|
||||
/// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored
|
||||
/// orientation): the developed frame is cropped to it once it is upright.
|
||||
pub crop: Option<crate::camera_raw_xmp::Crop>,
|
||||
}
|
||||
|
||||
/// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure,
|
||||
@@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
|
||||
let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?;
|
||||
let cropped = match tuning.crop {
|
||||
Some(crop) => {
|
||||
let o = if opts.orient { dev.info.orientation } else { 1 };
|
||||
crop_developed(&mut dev, crop.oriented(o))?;
|
||||
format!(", cropped to {}×{}", dev.width, dev.height)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let wb = if wb_applied {
|
||||
format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint)
|
||||
} else {
|
||||
@@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
||||
.filter(|(_, v)| *v != 0.0)
|
||||
.map(|(k, v)| format!(", {k} {v:+.0}"))
|
||||
.collect::<String>();
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id());
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id());
|
||||
Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied))
|
||||
}
|
||||
|
||||
/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw.
|
||||
fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> {
|
||||
let (w, h) = (dev.width as usize, dev.height as usize);
|
||||
let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size")));
|
||||
let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) };
|
||||
let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3);
|
||||
for y in y0..y1 {
|
||||
let row = y.checked_mul(w).ok_or_else(bad)?;
|
||||
rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?);
|
||||
}
|
||||
dev.rgb = rgb;
|
||||
dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?;
|
||||
dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour
|
||||
/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw
|
||||
/// this crate decodes). For previews of the open-time raw-stage sliders
|
||||
|
||||
@@ -44,6 +44,7 @@ mod orientation;
|
||||
mod pattern_fill_psd;
|
||||
mod pcraft_full_composite;
|
||||
mod pcraft_manifest_name;
|
||||
mod pdf_import;
|
||||
mod pdn_corpus;
|
||||
mod png_steps;
|
||||
mod png_text_metadata;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! PDF import end-to-end through craft-pdf (pure Rust, no shared library).
|
||||
//!
|
||||
//! A six-page synthetic PDF (plain, CropBox offset, rotations 90/180/270 and an oversized
|
||||
//! CropBox) imports at 72 and 300 DPI as one artboard per page, with the page's print size kept,
|
||||
//! a blue rectangle sampled from the raster, and the renderer's side/pixel limits respected by
|
||||
//! lowering the effective DPI on a huge page.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lopdf::{Document as PdfDocument, Object, Stream, dictionary};
|
||||
use photocraft_doc::LayerContent;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt};
|
||||
use photocraft_raster::Interrupt;
|
||||
|
||||
const PAGE_W: f64 = 200.0;
|
||||
const PAGE_H: f64 = 100.0;
|
||||
|
||||
/// Six one-page variants: plain, CropBox offset to (10, 20, 210, 120), rotations of 90/180/270
|
||||
/// degrees and an oversized CropBox (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page
|
||||
/// draws a 40 x 40 blue rectangle at (20, 30) in PDF space.
|
||||
fn variant_pdf() -> Vec<u8> {
|
||||
let pages: [(Option<i64>, Option<[f32; 4]>); 6] = [
|
||||
(None, None),
|
||||
(None, Some([10.0, 20.0, 210.0, 120.0])),
|
||||
(Some(90), None),
|
||||
(Some(180), None),
|
||||
(Some(270), None),
|
||||
(None, Some([-50.0, -50.0, 250.0, 150.0])),
|
||||
];
|
||||
let mut pdf = PdfDocument::with_version("1.5");
|
||||
let pages_id = pdf.new_object_id();
|
||||
let mut kids = Vec::new();
|
||||
for (rotate, crop) in pages {
|
||||
let content = b"0 0 1 rg 20 30 40 40 re f";
|
||||
let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec()));
|
||||
let mut page = dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(PAGE_W as f32), Object::Real(PAGE_H as f32)]),
|
||||
"Contents" => content_id,
|
||||
"Resources" => dictionary! {},
|
||||
};
|
||||
if let Some(rotate) = rotate {
|
||||
page.set("Rotate", rotate);
|
||||
}
|
||||
if let Some([left, bottom, right, top]) = crop {
|
||||
page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)]));
|
||||
}
|
||||
kids.push(Object::Reference(pdf.add_object(page)));
|
||||
}
|
||||
let count = kids.len() as i64;
|
||||
pdf.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => Object::Array(kids),
|
||||
"Count" => count,
|
||||
}),
|
||||
);
|
||||
let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||
pdf.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
pdf.save_to(&mut bytes).expect("writing the variant PDF");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// One page of `width` x `height` points with the same blue rectangle.
|
||||
fn one_page_pdf(width: f32, height: f32) -> Vec<u8> {
|
||||
let mut pdf = PdfDocument::with_version("1.5");
|
||||
let pages_id = pdf.new_object_id();
|
||||
let content_id = pdf.add_object(Stream::new(dictionary! {}, b"0 0 1 rg 20 30 40 40 re f".to_vec()));
|
||||
let page_id = pdf.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(width), Object::Real(height)]),
|
||||
"Contents" => content_id,
|
||||
"Resources" => dictionary! {},
|
||||
});
|
||||
pdf.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
|
||||
"Count" => 1,
|
||||
}),
|
||||
);
|
||||
let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||
pdf.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
pdf.save_to(&mut bytes).expect("writing the page PDF");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The first artboard rectangle of `document`.
|
||||
fn first_artboard(document: &photocraft_doc::Document) -> Rect {
|
||||
let layer = document.layers.first().expect("an imported page group");
|
||||
let group = match &layer.content {
|
||||
LayerContent::Group(group) => group,
|
||||
_ => panic!("{} is not a group", layer.name),
|
||||
};
|
||||
group.artboard.as_ref().expect("page artboard").rect
|
||||
}
|
||||
|
||||
/// Each page becomes its own artboard at 72 and 300 DPI, with the print size preserved.
|
||||
#[test]
|
||||
fn imports_every_page_as_an_artboard_with_its_print_size() {
|
||||
let pdf = variant_pdf();
|
||||
for dpi in [72.0f32, 300.0] {
|
||||
let options = ImportOptions { pdf_dpi: dpi, ..Default::default() };
|
||||
let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import");
|
||||
assert_eq!(result.document.layers.len(), 6, "one artboard per page at {dpi} dpi");
|
||||
let mut top_left = 0i32;
|
||||
for (index, layer) in result.document.layers.iter().enumerate() {
|
||||
let group = match &layer.content {
|
||||
LayerContent::Group(group) => group,
|
||||
_ => panic!("{} is not a page group", layer.name),
|
||||
};
|
||||
let artboard = group.artboard.as_ref().expect("page artboard").rect;
|
||||
let expected = match index {
|
||||
// CropBox [10, 20, 210, 120] intersected with the MediaBox is 190 x 80 points.
|
||||
1 => (190.0, 80.0),
|
||||
// Rotations 90 and 270 swap the axes.
|
||||
2 | 4 => (100.0, 200.0),
|
||||
_ => (200.0, 100.0),
|
||||
};
|
||||
let print_width = f64::from(artboard.width()) * 72.0 / f64::from(dpi);
|
||||
let print_height = f64::from(artboard.height()) * 72.0 / f64::from(dpi);
|
||||
assert!((print_width - expected.0).abs() < 1.0, "page {} width is {print_width:.1} pt, expected {} at {dpi} dpi", index + 1, expected.0);
|
||||
assert!((print_height - expected.1).abs() < 1.0, "page {} height is {print_height:.1} pt, expected {} at {dpi} dpi", index + 1, expected.1);
|
||||
assert_eq!(artboard.x0, top_left, "pages lay out side by side from x=0");
|
||||
top_left = artboard.x1 + 80;
|
||||
let surface = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer");
|
||||
if index == 0 {
|
||||
// The rectangle's centre in PDF space is (40, 50); at the document's top-left
|
||||
// origin that is y = PAGE_H - 50.
|
||||
let scale = f64::from(dpi) / 72.0;
|
||||
let x = artboard.x0 + (40.0 * scale).round() as i32;
|
||||
let y = ((PAGE_H - 50.0) * scale).round() as i32;
|
||||
let mut pixel = [[0u8; 4]; 1];
|
||||
surface.read_rgba8_into(Rect::from_xywh(x, y, 1, 1), &mut pixel);
|
||||
assert!(pixel[0][2] > 120 && pixel[0][0] < 120, "page 1 should be blue inside the rectangle, got {:?}", pixel[0]);
|
||||
let mut background = [[0u8; 4]; 1];
|
||||
surface.read_rgba8_into(Rect::from_xywh(artboard.x0 + 2, 2, 1, 1), &mut background);
|
||||
assert!(background[0][0] > 240 && background[0][1] > 240 && background[0][2] > 240, "page background should be white, got {:?}", background[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page over the renderer's side limit lowers the effective DPI instead of failing.
|
||||
#[test]
|
||||
fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() {
|
||||
// 3000 pt at 300 dpi would be 12500 px wide, over craft-pdf's 8192 px side limit.
|
||||
let pdf = one_page_pdf(3000.0, 100.0);
|
||||
let options = ImportOptions { pdf_dpi: 300.0, ..Default::default() };
|
||||
let result = import_with_options_and_interrupt("wide.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import");
|
||||
assert!(result.warnings.iter().any(|warning| warning.contains("rendered at")), "warnings: {:?}", result.warnings);
|
||||
let artboard = first_artboard(&result.document);
|
||||
assert!(artboard.width() <= 8192, "width {} is over the renderer's side limit", artboard.width());
|
||||
assert!(result.document.resolution_dpi < 300.0, "the effective DPI should be lowered");
|
||||
let print_width = f64::from(artboard.width()) * 72.0 / f64::from(result.document.resolution_dpi);
|
||||
assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt");
|
||||
}
|
||||
|
||||
/// Cancelling at the public boundary or between pages returns Cancelled, never a document.
|
||||
///
|
||||
/// The renderer's watcher polls the interrupt while a page is being interpreted, so the number
|
||||
/// of checks is not fixed any more; these triggers do not depend on counting them.
|
||||
#[test]
|
||||
fn cancelling_before_and_between_pages_is_reported() {
|
||||
let pdf = variant_pdf();
|
||||
let options = ImportOptions { pdf_dpi: 72.0, ..Default::default() };
|
||||
|
||||
// The boundary check before dispatch: already cancelled.
|
||||
let always = || true;
|
||||
let ctl = Interrupt::cancel_only(&always);
|
||||
match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) {
|
||||
Err(IoError::Cancelled) => {}
|
||||
other => panic!("an already-cancelled import returned {:?}", other.map(|_| ())),
|
||||
}
|
||||
|
||||
// The per-page loop's check: the first page's progress cancels the next iteration.
|
||||
let cancelled = AtomicBool::new(false);
|
||||
let cancel = || cancelled.load(Ordering::SeqCst);
|
||||
let progress = |fraction: f32| {
|
||||
if fraction > 0.0 {
|
||||
cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
};
|
||||
let ctl = Interrupt::new(&cancel, &progress);
|
||||
match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) {
|
||||
Err(IoError::Cancelled) => {}
|
||||
other => panic!("cancelling after the first page returned {:?}", other.map(|_| ())),
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() {
|
||||
assert_eq!(r.document.depth, SampleType::U16);
|
||||
}
|
||||
|
||||
/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it
|
||||
/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned.
|
||||
#[test]
|
||||
fn camera_raw_tuning_crops_in_the_stored_orientation() {
|
||||
use photocraft_io::camera_raw_xmp::Crop;
|
||||
use photocraft_io::raw::{RawTuning, import_raw_tuned};
|
||||
let (w, h) = (40, 24);
|
||||
// Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it.
|
||||
let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 };
|
||||
for (orientation, size, [x0, y0, x1, y1]) in
|
||||
[(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])]
|
||||
{
|
||||
let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535));
|
||||
spec.as_shot_neutral = Some([0.6, 1.0, 0.8]);
|
||||
spec.orientation = orientation;
|
||||
let bytes = spec.build();
|
||||
let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap();
|
||||
let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap();
|
||||
assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}");
|
||||
assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}");
|
||||
assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings);
|
||||
let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap();
|
||||
let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap();
|
||||
// 16-bit RGB: 6 bytes a pixel.
|
||||
let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize];
|
||||
let expected: Vec<u8> = (y0..y1).flat_map(|y| row(y).to_vec()).collect();
|
||||
assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() {
|
||||
use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth),
|
||||
//! so nothing is converted through RGB; only alpha changes.
|
||||
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -16,8 +17,15 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke};
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct BgEraseSettings {
|
||||
pub sampling: Sampling,
|
||||
/// Which matching pixels are erased. [`Limits::FindEdges`] grades the erase over the Tolerance
|
||||
/// like [`Limits::Contiguous`] in this tool (#11904), unlike Color Replacement, where it turns
|
||||
/// the soft falloff off.
|
||||
pub limits: Limits,
|
||||
/// 0..1: largest per-channel difference from the sampled colour that is erased.
|
||||
/// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the
|
||||
/// largest increase plus the largest decrease over the colour channels (`dist`); an exact
|
||||
/// match is erased fully, then less the further a colour is, and colours at or beyond the
|
||||
/// Tolerance are kept (`graded_weight`). Protect Foreground Color still compares the
|
||||
/// foreground by the largest per-channel difference (`max_channel_dist`).
|
||||
pub tolerance: f32,
|
||||
/// Keep pixels that match the foreground colour.
|
||||
pub protect_foreground: bool,
|
||||
@@ -40,11 +48,39 @@ impl Default for BgEraseSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest per-channel difference over the colour channels.
|
||||
/// Colour distance of `a` from `b` over the colour channels: the largest increase plus the largest
|
||||
/// decrease (#11901). Changes in the same direction count like the largest of them; changes in
|
||||
/// opposite directions add up, as the reference application measures them (a red-to-green edge is
|
||||
/// about twice as far as one channel alone). One channel (Gray) gives the absolute difference.
|
||||
fn dist(a: &[f32], b: &[f32]) -> f32 {
|
||||
let (up, down) = a.iter().zip(b).fold((0.0f32, 0.0f32), |(up, down), (x, y)| {
|
||||
let diff = x - y;
|
||||
(up.max(diff), down.max(-diff))
|
||||
});
|
||||
up + down
|
||||
}
|
||||
|
||||
/// The largest per-channel difference of `a` from `b`: the distance Protect Foreground Color keeps
|
||||
/// using (#11901 measured only the sample match; #11903 keeps today's measure for the foreground).
|
||||
fn max_channel_dist(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs()))
|
||||
}
|
||||
|
||||
/// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact
|
||||
/// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after
|
||||
/// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept.
|
||||
/// Measured in the reference application with single clicks (one dab) at Tolerance 10 to 100.
|
||||
fn graded_weight(d: f32, tol: f32) -> f32 {
|
||||
if !d.is_finite() || d >= tol {
|
||||
return if d <= 0.0 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let d0 = (tol / 20.0).min(8.0 / 255.0);
|
||||
if d <= d0 {
|
||||
return 1.0;
|
||||
}
|
||||
(1.0 - (d - d0) / (tol - d0)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none);
|
||||
/// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no
|
||||
/// alpha channel.
|
||||
@@ -70,22 +106,15 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
|
||||
let a = alpha_index(&fmt)?;
|
||||
let nc = a;
|
||||
let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let soft = bs.limits != Limits::FindEdges;
|
||||
let native = |c: [f32; 4]| -> Vec<f32> { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() };
|
||||
let swatch = native(bs.background);
|
||||
let fg = native(bs.foreground);
|
||||
let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground);
|
||||
let float = fmt.sample == SampleType::F32;
|
||||
let mut once: Option<Option<Vec<f32>>> = None;
|
||||
let match_w = move |d: f32| -> f32 {
|
||||
if d > tol {
|
||||
0.0
|
||||
} else if soft && tol > 0.0 {
|
||||
// Soft falloff over the last quarter of the tolerance.
|
||||
((tol - d) / (tol * 0.25)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
// Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904):
|
||||
// the reference application keeps at least as much of a blurred transition with it.
|
||||
let match_w = move |d: f32| -> f32 { graded_weight(d, tol) };
|
||||
let pre = pre.clone();
|
||||
// The pixels before the stroke under the working copy, read once per working area. Sampling
|
||||
// and matching read these, so a dab landing on pixels earlier dabs already erased still
|
||||
@@ -117,27 +146,40 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
|
||||
};
|
||||
let Some(sampled) = sampled else { return };
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
// Per-pixel match weight from the pre-stroke colours: the contiguous fill passes through
|
||||
// pixels this stroke already erased.
|
||||
// Which pixels the contiguous fill may pass, from the pre-stroke colours as before: it
|
||||
// passes through pixels this stroke already erased.
|
||||
let mut reach = vec![0.0f32; w * h];
|
||||
// Per-pixel match weight from the working copy (#11902): a pixel an earlier dab erased
|
||||
// partly is matched by its current, decontaminated colour, which is pushed away from the
|
||||
// sampled colour, so later dabs erase it again only while it is still within the Tolerance.
|
||||
let mut weight = vec![0.0f32; w * h];
|
||||
let protected = |c: &[f32]| protect_foreground && max_channel_dist(c, &fg) <= tol;
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
if fp.at(x, y) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = orig.px(x, y);
|
||||
if p[a] <= 0.0 {
|
||||
let o = orig.px(x, y);
|
||||
if o[a] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = &p[..nc];
|
||||
if protect_foreground && dist(c, &fg) <= tol {
|
||||
continue;
|
||||
let i = (y - r.y0) as usize * w + (x - r.x0) as usize;
|
||||
if !protected(&o[..nc]) {
|
||||
reach[i] = match_w(dist(&o[..nc], &sampled));
|
||||
}
|
||||
let c = work.px(x, y);
|
||||
if c[a] > 0.0 && !protected(&c[..nc]) {
|
||||
weight[i] = match_w(dist(&c[..nc], &sampled));
|
||||
}
|
||||
weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled));
|
||||
}
|
||||
}
|
||||
if limits != Limits::Discontiguous {
|
||||
keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize);
|
||||
keep_connected(&mut reach, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize);
|
||||
for (wv, rv) in weight.iter_mut().zip(&reach) {
|
||||
if *rv <= 0.0 {
|
||||
*wv = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
@@ -145,16 +187,32 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let keep = 1.0 - k.min(1.0);
|
||||
let p = work.px_mut(x, y);
|
||||
p[a] *= 1.0 - k.min(1.0);
|
||||
if p[a] <= 0.0 {
|
||||
p[a] *= keep;
|
||||
if p[a] <= 0.0 || keep <= 0.0 {
|
||||
p.fill(0.0);
|
||||
continue;
|
||||
}
|
||||
decontaminate(&mut p[..nc], &sampled, keep, float);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Colour decontamination of a partly erased pixel (#11902): of the colour `c`, the share `1 - keep`
|
||||
/// the dab removed was the sampled colour, so what stays is pushed away from it until compositing
|
||||
/// it at `keep` over the sampled colour gives back `c`: `(c - sampled × (1 - keep)) / keep` per
|
||||
/// channel, in the document's own channels. Clipped to the channel range (0..1; 32-bit float keeps
|
||||
/// values above 1 it already had).
|
||||
fn decontaminate(c: &mut [f32], sampled: &[f32], keep: f32, float: bool) {
|
||||
for (v, s) in c.iter_mut().zip(sampled) {
|
||||
let hi = if float { v.max(1.0) } else { 1.0 };
|
||||
let d = (*v - s * (1.0 - keep)) / keep;
|
||||
*v = if d.is_finite() { d.clamp(0.0, hi) } else { v.clamp(0.0, hi) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeroes the weights not 4-connected to `(sx, sy)` through non-zero weights.
|
||||
fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) {
|
||||
let mut keep = vec![false; w * h];
|
||||
@@ -191,6 +249,18 @@ fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bg_erase_graded_alpha_tests.rs"]
|
||||
mod graded_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bg_erase_find_edges_tests.rs"]
|
||||
mod find_edges_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bg_erase_decontamination_tests.rs"]
|
||||
mod decontamination_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Background Eraser colour decontamination (#11902): a partly erased pixel loses the sampled
|
||||
//! colour's share, so composited over the sampled colour it gives back its original colour, and
|
||||
//! later dabs match its current, decontaminated colour. Expected values are the reference
|
||||
//! application's measurements from the issue (8-bit RGB, 40 px hard round tip, sample (200,40,40)).
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba};
|
||||
|
||||
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
|
||||
use crate::replace::{Limits, Sampling};
|
||||
use crate::{BrushSettings, Stroke, StrokePoint};
|
||||
|
||||
/// Block `i` of the reference test layer: (200, 40 + 20 i, 40).
|
||||
fn block(i: usize) -> [f32; 4] {
|
||||
[200.0 / 255.0, (40.0 + 20.0 * i as f32) / 255.0, 40.0 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
/// Eight 100 px blocks side by side, 900 x 300; the reference's drag row y 450 is y 150 here.
|
||||
fn blocks(fmt: PixelFormat) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
for i in 0..8 {
|
||||
let x = 100 * i as i32;
|
||||
s.fill_rect(Rect::new(x, 0, x + 100, 300), &from_rgba(&fmt, block(i)));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn stroke(points: &[(f64, f64)]) -> Stroke {
|
||||
Stroke {
|
||||
brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
|
||||
points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn once(tolerance_percent: f32) -> BgEraseSettings {
|
||||
BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: tolerance_percent / 100.0, ..Default::default() }
|
||||
}
|
||||
|
||||
fn swatch(tolerance_percent: f32) -> BgEraseSettings {
|
||||
BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: block(0), ..once(tolerance_percent) }
|
||||
}
|
||||
|
||||
/// RGBA 0-255 at block `i`'s centre.
|
||||
fn rgba(s: &Surface, i: usize) -> [i32; 4] {
|
||||
let p = s.rgba(100 * i as i32 + 50, 150);
|
||||
p.map(|v| (v * 255.0).round() as i32)
|
||||
}
|
||||
|
||||
/// The reference's drag from block 0's centre to `to_x`, Sampling Once.
|
||||
fn drag(fmt: PixelFormat, tolerance_percent: f32, to_x: f64) -> Surface {
|
||||
let mut s = blocks(fmt);
|
||||
apply_background_eraser(&mut s, &stroke(&[(50.0, 150.0), (to_x, 150.0)]), &once(tolerance_percent), None);
|
||||
s
|
||||
}
|
||||
|
||||
/// Separate single-click strokes on every block centre, `clicks` times, Background Swatch.
|
||||
fn clicks(fmt: PixelFormat, tolerance_percent: f32, clicks: usize) -> Surface {
|
||||
let mut s = blocks(fmt);
|
||||
for _ in 0..clicks {
|
||||
for i in 0..8 {
|
||||
apply_background_eraser(&mut s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &swatch(tolerance_percent), None);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn assert_alpha_row(s: &Surface, want: &[i32], what: &str) {
|
||||
let got: Vec<i32> = (0..want.len()).map(|i| rgba(s, i)[3]).collect();
|
||||
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!((g - w).abs() <= 8, "{what}: block {i} alpha {g}, measured {w} (row {got:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eight_block_drag_gives_the_measured_rgba() {
|
||||
const WANT: [[i32; 4]; 8] = [
|
||||
[200, 40, 40, 0],
|
||||
[200, 216, 40, 29],
|
||||
[200, 184, 40, 71],
|
||||
[200, 175, 40, 113],
|
||||
[200, 172, 40, 155],
|
||||
[200, 170, 40, 196],
|
||||
[200, 169, 40, 238],
|
||||
[200, 180, 40, 255],
|
||||
];
|
||||
let s = drag(PixelFormat::RGBA8, 50.0, 750.0);
|
||||
for (i, want) in WANT.iter().enumerate() {
|
||||
let got = rgba(&s, i);
|
||||
// Block 0 is fully erased: only its alpha is defined.
|
||||
let channels = if want[3] == 0 { 3..4 } else { 0..4 };
|
||||
for c in channels {
|
||||
assert!((got[c] - want[c]).abs() <= 8, "block {i}: got {got:?}, measured {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_table_of_11901_holds_with_decontamination() {
|
||||
let rows: [(f32, [i32; 8]); 5] = [
|
||||
(10.0, [0, 194, 255, 255, 255, 255, 255, 255]),
|
||||
(20.0, [0, 90, 197, 255, 255, 255, 255, 255]),
|
||||
(30.0, [0, 56, 126, 196, 255, 255, 255, 255]),
|
||||
(50.0, [0, 30, 75, 114, 156, 198, 238, 255]),
|
||||
(70.0, [0, 18, 48, 78, 107, 137, 167, 197]),
|
||||
];
|
||||
for (tol, want) in rows {
|
||||
assert_alpha_row(&drag(PixelFormat::RGBA8, tol, 750.0), &want, &format!("drag at Tolerance {tol}"));
|
||||
}
|
||||
// At Tolerance 100 the decontaminated colours stay inside the Tolerance, so every dab erases
|
||||
// a bit more; the drag on to 790 is the measured row (block 7 is no reference at 750).
|
||||
assert_alpha_row(&drag(PixelFormat::RGBA8, 100.0, 790.0), &[0, 7, 19, 32, 44, 56, 68, 81], "drag at Tolerance 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_strokes_match_the_decontaminated_colour() {
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
assert_alpha_row(&clicks(fmt, 100.0, 1), &[0, 12, 33, 54, 74, 95, 116, 136], "Tolerance 100, 1 click");
|
||||
assert_alpha_row(&clicks(fmt, 100.0, 2), &[0, 10, 28, 45, 62, 80, 97, 114], "Tolerance 100, 2 clicks");
|
||||
assert_alpha_row(&clicks(fmt, 100.0, 3), &[0, 8, 23, 38, 52, 67, 81, 96], "Tolerance 100, 3 clicks");
|
||||
// At Tolerance 50 the decontaminated colours are outside the Tolerance: a second click leaves
|
||||
// the block centres as they were.
|
||||
let one = clicks(fmt, 50.0, 1);
|
||||
assert_alpha_row(&one, &[0, 29, 71, 113, 155, 196, 238, 255], "Tolerance 50, 1 click");
|
||||
let two = clicks(fmt, 50.0, 2);
|
||||
for i in 1..8 {
|
||||
assert_eq!(rgba(&two, i), rgba(&one, i), "Tolerance 50, 2 clicks: block {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partly_erased_pixels_composite_back_to_their_original_colour() {
|
||||
// Composited over the sampled colour, each partly erased block centre gives back its original
|
||||
// colour within 2 levels, after one click and after the drag.
|
||||
for s in [clicks(PixelFormat::RGBA8, 50.0, 1), drag(PixelFormat::RGBA8, 50.0, 750.0)] {
|
||||
for i in 1..7 {
|
||||
let [r, g, b, a] = rgba(&s, i);
|
||||
assert!(a > 0 && a < 255, "block {i} partly erased, alpha {a}");
|
||||
let over = |c: i32, s: i32| (c * a + s * (255 - a)) as f32 / 255.0;
|
||||
let orig = block(i).map(|v| v * 255.0);
|
||||
for (k, (got, want)) in [over(r, 200), over(g, 40), over(b, 40)].iter().zip(orig).enumerate() {
|
||||
assert!((got - want).abs() <= 2.0, "block {i} channel {k}: composite {got}, original {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_and_erased_pixels_and_the_channel_range() {
|
||||
let s = drag(PixelFormat::RGBA8, 50.0, 750.0);
|
||||
// Fully kept: the colour exactly; outside the brush: untouched.
|
||||
assert_eq!(rgba(&s, 7), [200, 180, 40, 255]);
|
||||
assert_eq!(s.rgba(350, 10), blocks(PixelFormat::RGBA8).rgba(350, 10));
|
||||
// Fully erased: alpha 0.
|
||||
assert_eq!(rgba(&s, 0)[3], 0);
|
||||
// At Tolerance 100 the decontaminated green clips at 255 rather than wrapping, and 32-bit
|
||||
// float results stay finite and within 0..1 for in-range colours.
|
||||
let t = clicks(PixelFormat::RGBA8, 100.0, 1);
|
||||
assert_eq!(rgba(&t, 7)[1], 255, "clipped, not wrapped");
|
||||
let f = clicks(PixelFormat::RGBA32F, 100.0, 3);
|
||||
for x in (0..900).step_by(7) {
|
||||
for v in f.rgba(x, 150) {
|
||||
assert!(v.is_finite() && (0.0..=1.0).contains(&v), "x {x}: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_works_in_the_documents_own_channels() {
|
||||
// 16-bit RGB: the same RGBA as 8-bit.
|
||||
let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
|
||||
let a = drag(PixelFormat::RGBA8, 50.0, 750.0);
|
||||
let b = drag(rgb16, 50.0, 750.0);
|
||||
for i in 0..8 {
|
||||
let (x, y) = (rgba(&a, i), rgba(&b, i));
|
||||
for c in 0..4 {
|
||||
assert!((x[c] - y[c]).abs() <= 2 || x[3] == 0, "16-bit block {i}: {y:?} against 8-bit {x:?}");
|
||||
}
|
||||
}
|
||||
// Gray (8 and 16 bit): the gray level is decontaminated the same way, so the composite over
|
||||
// the sampled gray gives back the original level, and a second click at Tolerance 50 changes
|
||||
// nothing at the block centres.
|
||||
for sample in [SampleType::U8, SampleType::U16] {
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, sample, true);
|
||||
let level = |i: usize| (40.0 + 20.0 * i as f32) / 255.0;
|
||||
let mut s = Surface::new(gray);
|
||||
for i in 0..8 {
|
||||
let x = 100 * i as i32;
|
||||
s.fill_rect(Rect::new(x, 0, x + 100, 300), &[level(i), 1.0]);
|
||||
}
|
||||
let bs = BgEraseSettings { background: [level(0), level(0), level(0), 1.0], ..swatch(50.0) };
|
||||
let click_all = |s: &mut Surface| {
|
||||
for i in 0..8 {
|
||||
apply_background_eraser(s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &bs, None);
|
||||
}
|
||||
};
|
||||
click_all(&mut s);
|
||||
let first = s.clone();
|
||||
for i in 1..7 {
|
||||
let p = s.pixel(100 * i as i32 + 50, 150);
|
||||
let (v, a) = (p[0], p[1]);
|
||||
assert!(a > 0.0 && a < 1.0, "{sample:?} block {i} partly erased, alpha {a}");
|
||||
let over = v * a + level(0) * (1.0 - a);
|
||||
assert!((over - level(i)).abs() <= 2.0 / 255.0, "{sample:?} block {i}: composite {over}, original {}", level(i));
|
||||
}
|
||||
click_all(&mut s);
|
||||
for i in 1..8 {
|
||||
let at = (100 * i + 50, 150);
|
||||
assert_eq!(s.pixel(at.0, at.1), first.pixel(at.0, at.1), "{sample:?} block {i}: a second click at Tolerance 50 changes nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Background Eraser Limits Find Edges (#11904): a blurred edge is erased graded, keeping at least
|
||||
//! as much of the transition as Contiguous does, instead of cut hard at the Tolerance. Expected
|
||||
//! values are the reference application's measurements from the issue (8-bit RGB, 40 px and 60 px
|
||||
//! hard round tips, Tolerance 50, Sampling Continuous).
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba};
|
||||
|
||||
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
|
||||
use crate::replace::{Limits, Sampling};
|
||||
use crate::{BrushSettings, Stroke, StrokePoint};
|
||||
|
||||
/// Measured red channel along y 70 from x 185 across the 4 px Gaussian red|green edge at x 200.
|
||||
const RED: [i32; 30] = [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30];
|
||||
/// Alpha along y 70 from x 185 after a click at 190,70.
|
||||
const FIND_EDGES: [i32; 15] = [8, 8, 8, 4, 4, 0, 0, 0, 17, 42, 76, 121, 176, 238, 255];
|
||||
const CONTIGUOUS: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255];
|
||||
|
||||
fn rgb(c: [i32; 3]) -> [f32; 4] {
|
||||
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
/// The issue's test layer: red | green | blue | pink stripes (y 0-300), a white gap at y 140-160
|
||||
/// across the red stripe, and the red|green edge blurred over y 0-130. A Gaussian blur mixes every
|
||||
/// channel with the same weights, so green and blue follow red's mix fraction.
|
||||
fn layer() -> Surface {
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut s = Surface::new(fmt);
|
||||
for (x0, c) in [(0, [220, 30, 30]), (200, [30, 180, 60]), (400, [40, 60, 220]), (600, [240, 120, 120])] {
|
||||
s.fill_rect(Rect::new(x0, 0, x0 + 200, 300), &from_rgba(&fmt, rgb(c)));
|
||||
}
|
||||
s.fill_rect(Rect::new(0, 140, 200, 160), &from_rgba(&fmt, rgb([255, 255, 255])));
|
||||
for (i, r) in RED.iter().enumerate() {
|
||||
let t = (220 - r) as f32 / 190.0;
|
||||
let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0];
|
||||
let x = 185 + i as i32;
|
||||
s.fill_rect(Rect::new(x, 0, x + 1, 130), &from_rgba(&fmt, c));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn click(at: (f64, f64), size: f32, limits: Limits) -> Surface {
|
||||
let mut s = layer();
|
||||
let stroke = Stroke {
|
||||
brush: BrushSettings { size, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
|
||||
points: vec![StrokePoint::new(at.0, at.1, 1.0)],
|
||||
};
|
||||
let bs = BgEraseSettings { sampling: Sampling::Continuous, limits, tolerance: 0.5, ..Default::default() };
|
||||
apply_background_eraser(&mut s, &stroke, &bs, None);
|
||||
s
|
||||
}
|
||||
|
||||
fn alpha(s: &Surface, x: i32, y: i32) -> i32 {
|
||||
(s.rgba(x, y)[3] * 255.0).round() as i32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_edges_grades_a_blurred_edge_instead_of_cutting_it() {
|
||||
let fe = click((190.0, 70.0), 40.0, Limits::FindEdges);
|
||||
let co = click((190.0, 70.0), 40.0, Limits::Contiguous);
|
||||
for (i, want) in FIND_EDGES.iter().enumerate() {
|
||||
let x = 185 + i as i32;
|
||||
let (f, c) = (alpha(&fe, x, 70), alpha(&co, x, 70));
|
||||
assert!((f - want).abs() <= 10, "x {x}: Find Edges alpha {f}, measured {want}");
|
||||
assert!(f >= c, "x {x}: Find Edges keeps {f}, less than Contiguous's {c}");
|
||||
}
|
||||
// Past the transition nothing is erased (before, Find Edges cut through x 201).
|
||||
for x in 200..230 {
|
||||
assert_eq!(alpha(&fe, x, 70), 255, "x {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_edges_on_a_hard_edge_gives_the_contiguous_result() {
|
||||
// 60 px tip at 100,138 on red just above the white gap: column x 100 erased through y 139, the
|
||||
// gap and the red below it kept.
|
||||
let fe = click((100.0, 138.0), 60.0, Limits::FindEdges);
|
||||
let co = click((100.0, 138.0), 60.0, Limits::Contiguous);
|
||||
for y in 100..176 {
|
||||
assert_eq!(alpha(&fe, 100, y), alpha(&co, 100, y), "y {y}");
|
||||
}
|
||||
for y in 110..140 {
|
||||
assert_eq!(alpha(&fe, 100, y), 0, "red above the gap, y {y}");
|
||||
}
|
||||
for y in 140..176 {
|
||||
assert_eq!(alpha(&fe, 100, y), 255, "the gap and the red below it, y {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_and_discontiguous_keep_their_results() {
|
||||
// This item changes only Find Edges: the other two limits still give the measured row.
|
||||
for limits in [Limits::Contiguous, Limits::Discontiguous] {
|
||||
let s = click((190.0, 70.0), 40.0, limits);
|
||||
for (i, want) in CONTIGUOUS.iter().enumerate() {
|
||||
let x = 185 + i as i32;
|
||||
assert!((alpha(&s, x, 70) - want).abs() <= 8, "{limits:?} x {x}: {} against {want}", alpha(&s, x, 70));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Background Eraser graded alpha (#11901): one dab erases a colour less the further it is from the
|
||||
//! sampled colour, over the whole Tolerance, not fully up to the Tolerance. Expected values are the
|
||||
//! reference application's single-click measurements from the issue (8-bit RGB, 40 px hard round
|
||||
//! tip, Sampling Background Swatch with background (200,40,40), alpha at the patch centre).
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba};
|
||||
|
||||
use crate::bg_erase::{BgEraseSettings, apply_background_eraser};
|
||||
use crate::replace::{Limits, Sampling};
|
||||
use crate::{BrushSettings, Stroke, StrokePoint};
|
||||
|
||||
/// The sampled colour of every measurement: (200,40,40).
|
||||
const SAMPLE: [i32; 3] = [200, 40, 40];
|
||||
|
||||
/// Allowed deviation from the measured alpha ("within 8 levels").
|
||||
const LEVELS: i32 = 8;
|
||||
|
||||
fn rgb(c: [i32; 3]) -> [f32; 4] {
|
||||
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
fn click(at: (f64, f64)) -> Stroke {
|
||||
Stroke {
|
||||
brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
|
||||
points: vec![StrokePoint::new(at.0, at.1, 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
fn swatch(tolerance_percent: f32, background: [f32; 4]) -> BgEraseSettings {
|
||||
BgEraseSettings {
|
||||
sampling: Sampling::BackgroundSwatch,
|
||||
limits: Limits::Discontiguous,
|
||||
tolerance: tolerance_percent / 100.0,
|
||||
background,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One click in the centre of a 100 px patch of `colour` (straight RGBA) in `fmt`; alpha 0-255 at
|
||||
/// the centre.
|
||||
fn one_click(fmt: PixelFormat, colour: [f32; 4], bs: &BgEraseSettings) -> i32 {
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 100, 100), &from_rgba(&fmt, colour));
|
||||
apply_background_eraser(&mut s, &click((50.0, 50.0)), bs, None);
|
||||
(s.rgba(50, 50)[3] * 255.0).round() as i32
|
||||
}
|
||||
|
||||
/// The measured single-click table: (Tolerance, [(green difference d, alpha)]).
|
||||
fn single_click_table() -> Vec<(f32, Vec<(i32, i32)>)> {
|
||||
vec![
|
||||
(10.0, vec![(10, 92), (20, 194), (30, 255), (40, 255)]),
|
||||
(30.0, vec![(10, 21), (20, 56), (30, 91), (40, 126), (50, 161), (60, 196), (80, 255), (100, 255)]),
|
||||
(50.0, vec![(10, 8), (20, 29), (30, 50), (40, 71), (50, 92), (60, 113), (80, 155), (100, 196), (120, 238), (140, 255)]),
|
||||
(70.0, vec![(10, 3), (20, 18), (30, 33), (40, 48), (50, 63), (60, 78), (80, 107), (100, 137), (120, 167), (140, 197), (160, 227)]),
|
||||
(100.0, vec![(10, 2), (20, 12), (30, 23), (40, 33), (50, 43), (60, 54), (80, 74), (100, 95), (120, 116), (140, 136), (160, 157)]),
|
||||
]
|
||||
}
|
||||
|
||||
fn assert_near(got: i32, want: i32, what: &str) {
|
||||
assert!((got - want).abs() <= LEVELS, "{what}: got alpha {got}, measured {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_click_falls_off_over_the_whole_tolerance() {
|
||||
let bs = |tol| swatch(tol, rgb(SAMPLE));
|
||||
for (tol, row) in single_click_table() {
|
||||
for (d, want) in row {
|
||||
let got = one_click(PixelFormat::RGBA8, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &bs(tol));
|
||||
assert_near(got, want, &format!("Tolerance {tol}, green +{d}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_sign_differences_count_once_and_opposite_signs_add_up() {
|
||||
// Measured at Tolerance 50, single clicks, sample (200,40,40): (difference per channel, alpha).
|
||||
let rows: [([i32; 3], i32); 15] = [
|
||||
([0, 20, 0], 29),
|
||||
([-20, 0, 0], 29),
|
||||
([0, 0, 20], 29),
|
||||
([0, 20, 20], 29),
|
||||
([0, 40, 20], 71),
|
||||
([0, 20, 40], 71),
|
||||
([0, 60, 20], 113),
|
||||
([-20, 20, 0], 71),
|
||||
([-40, 40, 0], 155),
|
||||
([-60, 60, 0], 238),
|
||||
([10, 10, 10], 8),
|
||||
([20, 20, 20], 29),
|
||||
([40, 40, 40], 71),
|
||||
([-10, -10, -10], 8),
|
||||
([-40, -40, -40], 71),
|
||||
];
|
||||
let bs = swatch(50.0, rgb(SAMPLE));
|
||||
for (diff, want) in rows {
|
||||
let colour = [SAMPLE[0] + diff[0], SAMPLE[1] + diff[1], SAMPLE[2] + diff[2]];
|
||||
assert_near(one_click(PixelFormat::RGBA8, rgb(colour), &bs), want, &format!("difference {diff:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blurred_edge_keeps_its_transition_graded() {
|
||||
// Red (220,30,30) | green (30,180,60) at x 200, blurred with a 4 px Gaussian; the measured red
|
||||
// channel along y 70 from x 185. A Gaussian blur mixes every channel with the same weights, so
|
||||
// green and blue follow red's mix fraction.
|
||||
const RED: [i32; 30] =
|
||||
[220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30];
|
||||
const ALPHA: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255];
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 185, 140), &from_rgba(&fmt, rgb([220, 30, 30])));
|
||||
s.fill_rect(Rect::new(215, 0, 400, 140), &from_rgba(&fmt, rgb([30, 180, 60])));
|
||||
for (i, r) in RED.iter().enumerate() {
|
||||
let t = (220 - r) as f32 / 190.0;
|
||||
let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0];
|
||||
let x = 185 + i as i32;
|
||||
s.fill_rect(Rect::new(x, 0, x + 1, 140), &from_rgba(&fmt, c));
|
||||
}
|
||||
for limits in [Limits::Contiguous, Limits::Discontiguous] {
|
||||
let mut t = s.clone();
|
||||
let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.5, ..Default::default() };
|
||||
apply_background_eraser(&mut t, &click((190.0, 70.0)), &bs, None);
|
||||
for (i, want) in ALPHA.iter().enumerate() {
|
||||
let x = 185 + i as i32;
|
||||
let got = (t.rgba(x, 70)[3] * 255.0).round() as i32;
|
||||
assert_near(got, *want, &format!("{limits:?} x {x}"));
|
||||
}
|
||||
for x in 200..215 {
|
||||
assert_eq!(t.rgba(x, 70)[3], 1.0, "{limits:?} x {x} beyond the Tolerance kept");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exact_match_is_erased_and_far_colours_are_kept_at_every_tolerance() {
|
||||
for tol in [0.0, 1.0, 10.0, 30.0, 50.0, 70.0, 100.0] {
|
||||
let bs = swatch(tol, rgb(SAMPLE));
|
||||
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 0, "Tolerance {tol}: the sample itself");
|
||||
if tol < 100.0 {
|
||||
// Opposite-sign differences of 255 + 215: beyond every Tolerance below 100 %.
|
||||
assert_eq!(one_click(PixelFormat::RGBA8, rgb([0, 255, 40]), &bs), 255, "Tolerance {tol}: a far colour");
|
||||
}
|
||||
}
|
||||
// Tolerance 0 erases nothing but the exact colour.
|
||||
assert_eq!(one_click(PixelFormat::RGBA8, rgb([200, 41, 40]), &swatch(0.0, rgb(SAMPLE))), 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distance_is_measured_in_the_documents_own_channels() {
|
||||
// 16-bit RGB: the same single-click table as 8-bit.
|
||||
let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
|
||||
for (tol, row) in single_click_table() {
|
||||
for (d, want) in row {
|
||||
let got = one_click(rgb16, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &swatch(tol, rgb(SAMPLE)));
|
||||
assert_near(got, want, &format!("16-bit Tolerance {tol}, green +{d}"));
|
||||
}
|
||||
}
|
||||
// Gray (8 and 16 bit): the distance is the absolute difference of the gray levels, so a gray
|
||||
// patch d levels from the swatch erases like a one-channel difference of d in RGB, either way.
|
||||
for sample in [SampleType::U8, SampleType::U16] {
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, sample, true);
|
||||
let level = |v: i32| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0];
|
||||
for (tol, row) in single_click_table() {
|
||||
for (d, want) in row {
|
||||
let bs = swatch(tol, level(60));
|
||||
if 60 + d <= 255 {
|
||||
assert_near(one_click(gray, level(60 + d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, +{d}"));
|
||||
}
|
||||
if d <= 60 {
|
||||
assert_near(one_click(gray, level(60 - d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, -{d}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_foreground_keeps_todays_distance_to_the_foreground() {
|
||||
// #11903 measured the foreground only one channel away and keeps today's measure otherwise:
|
||||
// the largest per-channel difference. This foreground is 100 levels off in red and in green
|
||||
// (opposite signs): within Tolerance 50 by that measure, though the sample-match distance of
|
||||
// #11901 adds the two to 200. The pixel is the sampled colour, so only the protection keeps it.
|
||||
let bs = BgEraseSettings { protect_foreground: true, foreground: rgb([100, 140, 40]), ..swatch(50.0, rgb(SAMPLE)) };
|
||||
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 255);
|
||||
// Protection off: the same click erases the sample fully.
|
||||
assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &BgEraseSettings { protect_foreground: false, ..bs }), 0);
|
||||
}
|
||||
@@ -96,6 +96,14 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut
|
||||
ReplaceMode::Luminosity => BlendMode::Luminosity,
|
||||
};
|
||||
let fg = [rs.color[0], rs.color[1], rs.color[2]];
|
||||
// In a Lab/8 document Color mode keeps the pixel's L and takes the foreground's a and b, as in
|
||||
// the reference application (#11922): the stored a, b channels move straight to its. Lab/16
|
||||
// wasn't measured and keeps the RGB blend.
|
||||
let lab_color = fmt.mode == photocraft_color::ColorMode::Lab && fmt.sample == photocraft_color::SampleType::U8 && rs.mode == ReplaceMode::Color;
|
||||
let fg_ab = {
|
||||
let l = photocraft_color::convert::srgb_to_lab(fg);
|
||||
[(l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0]
|
||||
};
|
||||
let tol = rs.tolerance.clamp(0.0, 1.0);
|
||||
let soft = rs.anti_alias && rs.limits != Limits::FindEdges;
|
||||
let mut once: Option<[f32; 3]> = None;
|
||||
@@ -189,6 +197,11 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut
|
||||
continue;
|
||||
}
|
||||
let px = work.px_mut(x, y);
|
||||
if lab_color {
|
||||
px[1] += (fg_ab[0] - px[1]) * k;
|
||||
px[2] += (fg_ab[1] - px[2]) * k;
|
||||
continue;
|
||||
}
|
||||
let c = to_rgba(&fmt, px);
|
||||
let b = blend_rgb(mode, [c[0], c[1], c[2]], fg);
|
||||
let o = [c[0] + (b[0] - c[0]) * k, c[1] + (b[1] - c[1]) * k, c[2] + (b[2] - c[2]) * k, c[3]];
|
||||
@@ -199,6 +212,10 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "replace_lab_tests.rs"]
|
||||
mod replace_lab_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! #11922: in a Lab document, Color mode keeps the pixel's L and takes the foreground's a and b.
|
||||
//! The setup is the issue's: red, green and pink stripes and the darker block (200,40,40), a blue
|
||||
//! foreground, a 40 px hard tip, one click on each. Hue, Saturation and Luminosity keep today's RGB
|
||||
//! blend.
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
use super::*;
|
||||
use crate::{BrushSettings, StrokePoint};
|
||||
|
||||
const LAB8: PixelFormat = PixelFormat { mode: ColorMode::Lab, sample: SampleType::U8, alpha: true };
|
||||
const COLOURS: [[u8; 3]; 4] = [[220, 30, 30], [30, 180, 60], [240, 120, 120], [200, 40, 40]];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
/// A Lab/8 surface with the four colours in 100 px columns.
|
||||
fn target() -> Surface {
|
||||
target_in(LAB8)
|
||||
}
|
||||
|
||||
fn target_in(fmt: PixelFormat) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
for (i, c) in COLOURS.iter().enumerate() {
|
||||
let rgba = [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0];
|
||||
let x0 = i as i32 * 100;
|
||||
s.fill_rect(Rect::new(x0, 0, x0 + 100, 100), &photocraft_raster::from_rgba(&fmt, rgba));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn click(s: &mut Surface, mode: ReplaceMode, x: f64) {
|
||||
let stroke = Stroke {
|
||||
brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() },
|
||||
points: vec![StrokePoint::new(x, 50.0, 1.0)],
|
||||
};
|
||||
let rs = ReplaceSettings { mode, color: BLUE, ..Default::default() };
|
||||
apply_color_replacement(s, &stroke, &rs, None, false);
|
||||
}
|
||||
|
||||
/// The stored L (0..100) and a, b (-128..127) at a pixel.
|
||||
fn lab(s: &Surface, x: i32, y: i32) -> [f32; 3] {
|
||||
let px = s.pixel(x, y);
|
||||
[px[0] * 100.0, px[1] * 255.0 - 128.0, px[2] * 255.0 - 128.0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_keeps_l_and_takes_the_foregrounds_a_and_b() {
|
||||
let fg = photocraft_color::convert::srgb_to_lab([0.0, 0.0, 1.0]);
|
||||
for i in 0..COLOURS.len() {
|
||||
let mut s = target();
|
||||
let x = i as i32 * 100 + 50;
|
||||
let before = lab(&s, x, 50);
|
||||
click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5);
|
||||
let after = lab(&s, x, 50);
|
||||
assert!((after[0] - before[0]).abs() <= 1.0, "colour {i}: L {} → {}", before[0], after[0]);
|
||||
assert!((after[1] - fg[1]).abs() <= 2.0 && (after[2] - fg[2]).abs() <= 2.0, "colour {i}: a, b {:?} vs the foreground's {:?}", &after[1..], &fg[1..]);
|
||||
// Outside the tip nothing changed.
|
||||
assert_eq!(lab(&s, x - 40, 50), before, "colour {i} outside the tip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_and_luminosity_keep_todays_blend() {
|
||||
for mode in [ReplaceMode::Hue, ReplaceMode::Saturation, ReplaceMode::Luminosity] {
|
||||
let mut s = target();
|
||||
let pre = s.pixel(150, 50).to_vec();
|
||||
click(&mut s, mode, 150.5);
|
||||
// Today's path: through RGB, blended, and back.
|
||||
let blend = match mode {
|
||||
ReplaceMode::Hue => BlendMode::Hue,
|
||||
ReplaceMode::Saturation => BlendMode::Saturation,
|
||||
_ => BlendMode::Luminosity,
|
||||
};
|
||||
let c = to_rgba(&LAB8, &pre);
|
||||
let b = blend_rgb(blend, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]);
|
||||
let want = photocraft_raster::from_rgba(&LAB8, [b[0], b[1], b[2], c[3]]);
|
||||
let got = s.pixel(150, 50);
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g - w).abs() <= 1.0 / 255.0 + 1e-6, "{mode:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_16_color_keeps_todays_blend() {
|
||||
// Lab/16 wasn't measured: its Color mode still blends through RGB.
|
||||
let lab16 = PixelFormat { sample: SampleType::U16, ..LAB8 };
|
||||
for i in 0..COLOURS.len() {
|
||||
let mut s = target_in(lab16);
|
||||
let x = i as i32 * 100 + 50;
|
||||
let pre = s.pixel(x, 50).to_vec();
|
||||
click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5);
|
||||
let c = to_rgba(&lab16, &pre);
|
||||
let b = blend_rgb(BlendMode::Color, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]);
|
||||
let want = photocraft_raster::from_rgba(&lab16, [b[0], b[1], b[2], c[3]]);
|
||||
let got = s.pixel(x, 50);
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g - w).abs() <= 1.0 / 65535.0 + 1e-6, "colour {i}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,10 @@ impl Manifest {
|
||||
return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len())));
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?;
|
||||
let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
|
||||
let mut man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
|
||||
// The name is shown as stored, so the invisible padding at its ends (white space, line breaks,
|
||||
// joiners, fillers) is dropped here.
|
||||
man.name = trim_name(&man.name).to_owned();
|
||||
man.validate()?;
|
||||
Ok(man)
|
||||
}
|
||||
@@ -143,9 +146,7 @@ impl Manifest {
|
||||
if !valid_id(&self.id) {
|
||||
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
|
||||
}
|
||||
let name = self.name.trim();
|
||||
let printable = name.chars().all(|c| !c.is_control() && !hidden_format(c)) && name.chars().any(visible);
|
||||
if !printable || name.chars().count() > 64 {
|
||||
if !printable_name(&self.name) {
|
||||
return Err(Error::Manifest("`name` must be 1-64 printable characters".into()));
|
||||
}
|
||||
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
|
||||
@@ -374,18 +375,23 @@ const FORMAT: &[(char, char)] = &[
|
||||
('\u{E0020}', '\u{E007F}'),
|
||||
];
|
||||
|
||||
/// Default-ignorable characters outside Cf (fillers, variation selectors): allowed in a name, but they
|
||||
/// draw nothing on their own.
|
||||
/// Default-ignorable characters outside Cf (fillers, variation selectors, unassigned ignorables), and
|
||||
/// the blank Braille pattern U+2800: allowed in a name, but they draw nothing on their own.
|
||||
const BLANK: &[(char, char)] = &[
|
||||
('\u{34F}', '\u{34F}'),
|
||||
('\u{115F}', '\u{1160}'),
|
||||
('\u{17B4}', '\u{17B5}'),
|
||||
('\u{180B}', '\u{180D}'),
|
||||
('\u{180F}', '\u{180F}'),
|
||||
('\u{2065}', '\u{2065}'),
|
||||
('\u{2800}', '\u{2800}'),
|
||||
('\u{3164}', '\u{3164}'),
|
||||
('\u{FE00}', '\u{FE0F}'),
|
||||
('\u{FFA0}', '\u{FFA0}'),
|
||||
('\u{E0100}', '\u{E01EF}'),
|
||||
('\u{FFF0}', '\u{FFF8}'),
|
||||
('\u{E0000}', '\u{E0000}'),
|
||||
('\u{E0002}', '\u{E001F}'),
|
||||
('\u{E0080}', '\u{E0FFF}'),
|
||||
];
|
||||
|
||||
fn in_ranges(c: char, ranges: &[(char, char)]) -> bool {
|
||||
@@ -399,6 +405,85 @@ fn hidden_format(c: char) -> bool {
|
||||
!matches!(c, '\u{200C}' | '\u{200D}') && in_ranges(c, FORMAT)
|
||||
}
|
||||
|
||||
/// Whether `name` (already trimmed) can be shown as a label: 1-64 characters, at least one of them
|
||||
/// visible, and no control characters, line or paragraph separators, or hidden format characters.
|
||||
pub fn printable_name(name: &str) -> bool {
|
||||
name.chars().nth(64).is_none()
|
||||
&& name.chars().any(visible)
|
||||
&& !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c))
|
||||
}
|
||||
|
||||
/// `name` without what pads its ends invisibly: white space, the zero-width (non-)joiners and the
|
||||
/// characters that draw nothing on their own (fillers, blank Braille, unassigned ignorables, stray
|
||||
/// selectors), so a name that looks like "Tone" is "Tone". What belongs to a character the name
|
||||
/// keeps stays: a variation selector after it (the emoji in "Love ❤️"), and a Hangul filler that
|
||||
/// is part of a syllable block (U+1160 after a leading consonant, U+115F before a vowel).
|
||||
pub fn trim_name(name: &str) -> &str {
|
||||
let pad = |c: char| c.is_whitespace() || matches!(c, '\u{200C}' | '\u{200D}') || in_ranges(c, BLANK);
|
||||
let mut start = name.len();
|
||||
let mut chars = name.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
// The choseong filler opens a syllable before a vowel, and before the jungseong filler U+1160
|
||||
// when a final consonant follows (U+115F U+1160 + a final consonant is how a lone final
|
||||
// consonant is written; U+115F U+1160 alone draws nothing).
|
||||
let opens_syllable = c == '\u{115F}'
|
||||
&& chars.peek().is_some_and(|&(j, n)| {
|
||||
vowel_jamo(n) || (n == '\u{1160}' && name.get(j + n.len_utf8()..).and_then(|r| r.chars().next()).is_some_and(trailing_jamo))
|
||||
});
|
||||
if !pad(c) || opens_syllable {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let s = name.get(start..).unwrap_or_default();
|
||||
let mut end = s.len();
|
||||
for (i, c) in s.char_indices().rev() {
|
||||
if !pad(c) {
|
||||
break;
|
||||
}
|
||||
end = i;
|
||||
}
|
||||
// Take back the mark that belongs to the last character kept: one per character, so a run of
|
||||
// selectors after it is still padding.
|
||||
let mut prev = s.get(..end).and_then(|h| h.chars().next_back());
|
||||
for c in s.get(end..).unwrap_or_default().chars() {
|
||||
let belongs = match prev {
|
||||
Some(p) => attaches(c) || (c == '\u{1160}' && leading_jamo(p)),
|
||||
None => false,
|
||||
};
|
||||
if !belongs {
|
||||
break;
|
||||
}
|
||||
end += c.len_utf8();
|
||||
prev = if attaches(c) { None } else { Some(c) };
|
||||
}
|
||||
s.get(..end).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The marks among [`BLANK`] that modify the character before them: the combining grapheme joiner,
|
||||
/// the Khmer inherent vowels, the Mongolian free variation selectors and the variation selectors.
|
||||
fn attaches(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{34F}' | '\u{17B4}' | '\u{17B5}' | '\u{180B}'..='\u{180D}' | '\u{180F}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'
|
||||
)
|
||||
}
|
||||
|
||||
/// A conjoining Hangul leading consonant (choseong), not the filler U+115F.
|
||||
fn leading_jamo(c: char) -> bool {
|
||||
matches!(c, '\u{1100}'..='\u{115E}' | '\u{A960}'..='\u{A97C}')
|
||||
}
|
||||
|
||||
/// A conjoining Hangul final consonant (jongseong).
|
||||
fn trailing_jamo(c: char) -> bool {
|
||||
matches!(c, '\u{11A8}'..='\u{11FF}' | '\u{D7CB}'..='\u{D7FB}')
|
||||
}
|
||||
|
||||
/// A conjoining Hangul vowel (jungseong) other than the vowel filler U+1160.
|
||||
fn vowel_jamo(c: char) -> bool {
|
||||
matches!(c, '\u{1161}'..='\u{11A7}' | '\u{D7B0}'..='\u{D7C6}')
|
||||
}
|
||||
|
||||
/// A character that draws something on its own.
|
||||
fn visible(c: char) -> bool {
|
||||
!c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK)
|
||||
@@ -631,6 +716,14 @@ mod tests {
|
||||
"\u{3164}\u{FE0F}",
|
||||
" \u{3000} ",
|
||||
"Tone\u{7}",
|
||||
"Tone\u{2028}Pro",
|
||||
"Tone\u{2029}Pro",
|
||||
"\u{2800}",
|
||||
" \u{2800}\u{2800} ",
|
||||
"\u{2065}",
|
||||
"\u{FFF0}",
|
||||
"\u{E0000}\u{E0002}",
|
||||
"\u{E0080}\u{E0FFF}",
|
||||
] {
|
||||
assert!(with_name(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
@@ -638,6 +731,35 @@ mod tests {
|
||||
for good in ["Tone…", "Kontrast ±", "می\u{200C}خواهم", "\u{1F469}\u{200D}\u{1F4BB} Code", "\u{2764}\u{FE0F} Love", "色调"] {
|
||||
assert_eq!(with_name(good).map(|m| m.name).ok().as_deref(), Some(good));
|
||||
}
|
||||
// White space and line breaks at the ends are dropped from the stored name, so the menu never
|
||||
// shows them, and padding does not count towards the limit.
|
||||
for (padded, stored) in [
|
||||
("\nTone", "Tone"),
|
||||
("Tone\r\n", "Tone"),
|
||||
("\u{2028}Tone ", "Tone"),
|
||||
(" Tone… ", "Tone…"),
|
||||
("Tone\u{2800}", "Tone"),
|
||||
("\u{3164}\u{2800}Tone\u{FFA0}", "Tone"),
|
||||
("\u{2764}\u{FE0F} Love\u{2800}", "\u{2764}\u{FE0F} Love"),
|
||||
("Tone\u{2065}\u{FFF0}", "Tone"),
|
||||
("\u{17B4}Tone", "Tone"),
|
||||
// A selector right after the last kept character belongs to it and stays; one cut off by padding goes.
|
||||
("Love \u{2764}\u{FE0F}\u{2800}", "Love \u{2764}\u{FE0F}"),
|
||||
("Tone \u{FE0F}", "Tone"),
|
||||
("\u{FE0F}Tone", "Tone"),
|
||||
("Love \u{2764}\u{FE0F}\u{FE0F}\u{FE0F}", "Love \u{2764}\u{FE0F}"),
|
||||
// Hangul filler jamo that complete a syllable are part of the name; the two fillers alone draw nothing.
|
||||
("\u{115F}\u{1160}Tone", "Tone"),
|
||||
("Tone \u{115F}\u{1160}", "Tone"),
|
||||
("Tone \u{1100}\u{1160}", "Tone \u{1100}\u{1160}"),
|
||||
("\u{115F}\u{1161} Tone", "\u{115F}\u{1161} Tone"),
|
||||
("\u{115F}\u{1160}\u{11A8} Tone", "\u{115F}\u{1160}\u{11A8} Tone"),
|
||||
// Khmer inherent vowels belong to the consonant before them.
|
||||
("Tone \u{1780}\u{17B5}", "Tone \u{1780}\u{17B5}"),
|
||||
] {
|
||||
assert_eq!(with_name(padded).map(|m| m.name).ok().as_deref(), Some(stored), "{padded:?}");
|
||||
}
|
||||
assert_eq!(with_name(&format!("{}{}", "x".repeat(64), " ".repeat(1000))).map(|m| m.name.len()).ok(), Some(64));
|
||||
// The documented limit counts characters, not bytes.
|
||||
assert!(with_name(&"é".repeat(64)).is_ok());
|
||||
assert!(with_name(&"é".repeat(65)).is_err());
|
||||
|
||||
@@ -295,6 +295,12 @@ impl FontDb {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)), None)
|
||||
}
|
||||
|
||||
/// [`Self::register_font_data`] with the family name overridden, so an embedded subset can be
|
||||
/// registered under a name that cannot shadow an installed full family. Returns the names added.
|
||||
pub fn register_font_data_as(&mut self, bytes: Vec<u8>, family: &str) -> Vec<String> {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)), Some(family))
|
||||
}
|
||||
|
||||
/// Registers embedded font data without copying it.
|
||||
fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<String> {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)), None)
|
||||
|
||||
@@ -142,6 +142,22 @@ impl TextEngine {
|
||||
layer.cache = Some(r.surface);
|
||||
r.rect
|
||||
}
|
||||
|
||||
/// Registers font bytes (for example a font embedded in an imported PDF) for later layout.
|
||||
/// Returns the family names the font provides, best first.
|
||||
pub fn register_font_data(&mut self, bytes: Vec<u8>) -> Vec<String> {
|
||||
self.fonts.register_font_data(bytes)
|
||||
}
|
||||
|
||||
/// [`Self::register_font_data`] under `family`, which cannot shadow an installed family.
|
||||
pub fn register_font_data_as(&mut self, bytes: Vec<u8>, family: &str) -> Vec<String> {
|
||||
self.fonts.register_font_data_as(bytes, family)
|
||||
}
|
||||
|
||||
/// Whether `family` is available to this engine.
|
||||
pub fn has_family(&mut self, family: &str) -> bool {
|
||||
self.fonts.has_family(family)
|
||||
}
|
||||
}
|
||||
|
||||
static SHARED: warm::Warm<std::sync::Mutex<TextEngine>> = warm::Warm::new();
|
||||
|
||||
@@ -227,7 +227,6 @@ impl PhotocraftApp {
|
||||
histogram_panel::restore_options(&mut app);
|
||||
// After the saved preferences, which say whether the set was already offered.
|
||||
kys_import::offer_import(&mut app);
|
||||
notices::wayland_file_drop_guidance(&mut app);
|
||||
// File › Scripts › Script Events Manager: "Start Application".
|
||||
photocraft_engine::automate_cmds::fire_event(&mut app.session, "startApplication");
|
||||
app
|
||||
|
||||
@@ -137,6 +137,7 @@ impl PhotocraftApp {
|
||||
order,
|
||||
question,
|
||||
policy_applied: false,
|
||||
smart_object: false,
|
||||
},
|
||||
);
|
||||
Ok(warnings)
|
||||
|
||||
@@ -121,7 +121,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
if !dropped.is_empty() {
|
||||
let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx));
|
||||
let at = self.cursor_pos(ctx);
|
||||
self.open_dropped(ctx, dropped, at);
|
||||
// Files dragged in from another app's window: PhotoCraft comes to the front and takes
|
||||
// the keyboard, as Photoshop does, instead of staying behind the source (#5687).
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! #11910: with the Background Eraser, the options-bar brush picker and a canvas right-click open
|
||||
//! a compact popup (Size, Hardness, Spacing, the angle/roundness widget, Angle, Roundness, the
|
||||
//! "Size:" and "Tolerance:" pen dropdowns with the tablet warning) and no preset list; other tools
|
||||
//! keep the Brush Preset picker.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
fn app(tool: Tool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap();
|
||||
app.ui.tool = tool;
|
||||
app.sync_views();
|
||||
app
|
||||
}
|
||||
|
||||
/// The canvas with the picker open as the brush chip opens it.
|
||||
fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
let ctx = h.ctx.clone();
|
||||
crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn top(h: &Harness<'static, PhotocraftApp>, label: &str) -> f32 {
|
||||
h.get_by_label(label).rect().top()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_popup_has_the_controls_in_order_and_no_presets() {
|
||||
let mut h = harness(app(Tool::BackgroundEraser));
|
||||
open(&mut h);
|
||||
let order = ["Size", "Hardness", "Spacing", "Angle", "Roundness", "Size:", "Tolerance:"];
|
||||
let tops: Vec<f32> = order
|
||||
.iter()
|
||||
.map(|l| match *l {
|
||||
// The Size field, the dropdowns by their combo boxes.
|
||||
"Size" => h.get_all_by_role(Role::SpinButton).next().expect("the Size field").rect().top(),
|
||||
"Size:" | "Tolerance:" => h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some(*l)).expect(l).rect().top(),
|
||||
"Hardness" | "Spacing" => h.query_all_by_label(l).next().expect(l).rect().top(),
|
||||
_ => top(&h, l),
|
||||
})
|
||||
.collect();
|
||||
assert!(tops.windows(2).all(|w| w[0] <= w[1]), "top to bottom {order:?}: {tops:?}");
|
||||
assert!(h.query_by_label(PRESSURE_WARNING).is_some(), "the warning triangle beside Tolerance:");
|
||||
assert!(h.query_by_label("Search Brushes").is_none() && h.query_by_label("Brush Preset Options").is_none(), "no presets, search or gear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_defaults_are_the_references() {
|
||||
let app = app(Tool::BackgroundEraser);
|
||||
assert_eq!(app.ui.tool_options.bg_tolerance_control, PEN_PRESSURE, "Tolerance: Pen Pressure by default");
|
||||
assert_eq!(size_control(&app.session.tools.brush), OFF, "Size: Off");
|
||||
let mut h = harness(app);
|
||||
open(&mut h);
|
||||
let value = |l: &str| {
|
||||
h.get_all_by_role(Role::ComboBox)
|
||||
.find(|n| n.accesskit_node().label().as_deref() == Some(l))
|
||||
.and_then(|n| n.accesskit_node().value())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(value("Size:"), "Off");
|
||||
assert_eq!(value("Tolerance:"), "Pen Pressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_is_stored_with_the_tool_and_size_with_its_brush() {
|
||||
let mut h = harness(app(Tool::BackgroundEraser));
|
||||
open(&mut h);
|
||||
h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")).unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Off").last().expect("the Off entry").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.tool_options.bg_tolerance_control, OFF);
|
||||
h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Size:")).unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Pen Pressure").last().expect("the Pen Pressure entry").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.tools.brush.pressure_size, "Size: Pen Pressure is the brush's pressure for size");
|
||||
// The mapping both ways.
|
||||
let mut b = BrushSettings::default();
|
||||
for c in [STYLUS_WHEEL, PEN_PRESSURE, OFF] {
|
||||
set_size_control(&mut b, c);
|
||||
assert_eq!(size_control(&b), c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_and_a_right_click_erases_nothing() {
|
||||
let mut h = harness(app(Tool::BackgroundEraser));
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(h.state().ui.brush_picker.is_some(), "the right-click opens the popup");
|
||||
assert!(h.query_all_by_label("Tolerance:").next().is_some(), "the compact popup, not the preset picker");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing erased");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.brush_picker, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_keep_the_brush_preset_picker() {
|
||||
let mut h = harness(app(Tool::Brush));
|
||||
open(&mut h);
|
||||
assert!(h.query_by_label("Brush Preset Options").is_some(), "the Brush's picker with its gear");
|
||||
assert!(h.query_by_label(PRESSURE_WARNING).is_none());
|
||||
}
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user