diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 1d110e28f2..6848088d37 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -29,3 +29,27 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: bash packaging/linux/verified-download-test.sh + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 64592d9cf0..71afc03e14 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -4,10 +4,13 @@ name: Perf (nightly) # Metal), every night and on demand. It never runs on pull requests, so it can't block one. # It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses # more than 15 % against perf/baseline.json from the same machine class (other classes are -# never compared). The table goes to the job summary; results.json is uploaded as an artifact. +# never compared; a baseline built from several runs is judged against its highest p50). The +# table goes to the job summary; results.json is uploaded as the `perf-results` artifact. # -# To give this runner class its own baseline: run it by hand with "update_baseline", download -# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR. +# To give this runner class its own baseline: download the `perf-results` artifact of the last +# five or more nightly runs (`gh run download -n perf-results -D runs/`), run +# `cargo xtask perf --update-baseline --from runs//results.json ... --allow-contended` (this +# runner is never idle) and commit perf/baseline.json and docs/scorecard.md in a PR. on: schedule: @@ -58,7 +61,8 @@ jobs: run: | args=() if [ "$QUICK" = "true" ]; then args+=(--quick); fi - if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi + # This runner is never idle: a baseline from it is recorded labelled as contended. + if [ "$UPDATE" = "true" ]; then args+=(--update-baseline --allow-contended); fi cargo xtask perf "${args[@]}" - name: Job summary diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 1022c54215..27111f3b94 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: photocraft app-dir: PhotoCraft + install-relative-path: Programs/ArtCraft/PhotoCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 172bc7bd70..e1ffe55928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, - **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s. - **Last-resort guard.** The app shell must catch an escaped panic around command dispatch and file import/export, reports it as an error and keeps the document. It's a safety net, not a licence to panic. Keep `panic = "unwind"`. - **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix. -- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic`/indexing in tests only. Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. +- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic` in tests only. Clean production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. 1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`). 2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fdec613767..83c583649f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -29,11 +29,12 @@ 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) | | 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 | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.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`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.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`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.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 | 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 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index b0e27d86c2..a7fa1b483f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,6 +1163,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1341,12 +1350,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "thiserror 2.0.21", ] @@ -1354,11 +1391,11 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree 0.20.0", + "miniz_oxide 0.9.1", + "roxmltree 0.21.1", "tempfile", "winprint", "winsafe", @@ -1367,20 +1404,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", + "skrifa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -2175,6 +2220,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2956,6 +3012,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -3001,6 +3074,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -3010,6 +3086,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -3039,6 +3141,7 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", "serde", "stable_deref_trait", "writeable", @@ -3048,6 +3151,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "icu_segmenter" version = "2.3.0" @@ -3720,6 +3847,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -4918,6 +5048,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raster", "photocraft-raw", "rmcp", "rustix 1.1.5", @@ -5118,6 +5249,7 @@ dependencies = [ "craft-docpath", "crc32fast", "flate2", + "miniz_oxide 0.9.1", "photocraft-affinity", "photocraft-algo", "photocraft-cms", @@ -5514,6 +5646,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -7176,7 +7318,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -7825,6 +7970,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -8864,11 +9015,20 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -9246,6 +9406,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", "zerovec", diff --git a/Cargo.toml b/Cargo.toml index a3b0ca4b36..e7a70d55b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ license = "MIT OR Apache-2.0" rust-version = "1.95" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } # internal photocraft-geom = { path = "crates/geom" } photocraft-color = { path = "crates/color" } @@ -39,10 +39,10 @@ photocraft-tablet = { path = "crates/tablet" } photocraft-ipp = { path = "crates/ipp" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -51,7 +51,7 @@ clap_complete = "4.6.11" clap_mangen = { version = "0.3.3", features = ["env"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } -flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] } image = { version = "0.25", default-features = false } # Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg. zune-core = "0.5" @@ -76,6 +76,9 @@ web-time = "1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index a4fe847cda..d1a3b68d3b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -193,6 +193,143 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde builder } +/// Attach the log file under the settings directory. Called after the arguments are parsed, so +/// `--version` and usage errors leave no file behind. Records logged until now are written first. +fn attach_log_file(logger: Option<&'static logging::AppLogger>) { + if let (Some(logger), Some(dir)) = (logger, services::config_dir()) { + match logger.attach_dir(&dir.join("logs")) { + Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + } + } +} + +/// One PhotoCraft per user (#3708). Only with the per-user settings folder (a portable copy's may +/// be shared between users), and not for automation, `--new-instance` or per-process options. +/// `Break` when the files were handed to the running PhotoCraft and this launch is done. +#[cfg(not(target_os = "macos"))] +fn claim_single_instance( + files: &[String], + new_instance: bool, + control: bool, + unreadable_paths: bool, + mut launch_options: Vec, +) -> std::ops::ControlFlow<(), Option> { + use std::ops::ControlFlow::{Break, Continue}; + let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); + // A variable documented as the equivalent of a per-launch flag counts as that flag: a + // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it + // keeps its own process instead of handing its files over and being ignored. + launch_options.extend_from_slice(&single_instance::env_launch_options()); + let context = single_instance::LaunchContext { + per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, + new_instance_flag: new_instance, + new_instance_env: new_instance_env.as_deref(), + control, + unreadable_paths, + launch_options: &launch_options, + }; + match (single_instance::skip_reason(&context), services::config_dir()) { + (Some(reason), _) => { + log::info!("running as a separate PhotoCraft: {reason}"); + Continue(None) + } + (None, None) => Continue(None), + (None, Some(dir)) => { + let paths: Vec = files.iter().map(|f| single_instance::absolute(f)).collect(); + match single_instance::claim(&dir, &paths) { + single_instance::Launch::Forwarded => { + log::info!("handed {} file(s) to the running PhotoCraft", paths.len()); + Break(()) + } + single_instance::Launch::Primary(primary) => Continue(Some(primary)), + single_instance::Launch::Standalone(reason) => { + log::warn!("starting a separate PhotoCraft: {reason}"); + Continue(None) + } + } + } + } +} + +/// The display server to open the window on: Xwayland for a pen on Wayland, which gives this app +/// no pen input (#639). Exits with the package to install when its libraries are missing. +#[cfg(target_os = "linux")] +fn linux_display_session() -> linux_libs::DisplaySession { + let session = tablet::display_session(); + // Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too, + // where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can + // run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own. + let session = if session == linux_libs::DisplaySession::Wayland + && gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11 + && std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) + && linux_libs::available(linux_libs::DisplaySession::X11) + { + eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)"); + linux_libs::DisplaySession::X11 + } else { + session + }; + // winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when + // one is missing (issue #201). Name the package to install and exit instead. + if let Err(message) = linux_libs::preflight(session) { + eprint!("{message}"); + std::process::exit(1); + } + session +} + +/// The control server's port, token and workspace, when it is on (exits 2 on a bad configuration). +fn control_setup( + cli_port: Option, + auth: photocraft_automation::args::ControlArgs, + automation: &photocraft_automation::args::AutomationArgs, +) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> { + let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref())); + if let Some(warning) = &warning { + eprint!("{warning}"); + } + let port = port?; + let (supplied, token_file) = (auth.control_token, auth.control_token_file); + let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { + Ok(token) => token, + Err(e) => { + eprintln!("photocraft: cannot configure control authentication: {e}"); + std::process::exit(2); + } + }; + if let Some(path) = token_file { + eprintln!("photocraft: control token file: {}", path.display()); + } else if supplied.is_none() { + eprintln!("photocraft: control token: {token}"); + } else { + eprintln!("photocraft: using supplied control token"); + } + let workspace = match automation.workspace() { + Ok(workspace) => workspace, + Err(error) => { + eprintln!("photocraft: cannot configure automation workspace: {error}"); + std::process::exit(2); + } + }; + Some((port, token, workspace)) +} + +/// Record this start's backend in the GPU startup marker, if the marker is locked. +fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup::Plan) { + if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() { + let backend = match &plan.env { + Some(v) => format!("env:{v}"), + None => plan.backend.name().to_string(), + }; + let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() }; + if let Err(e) = s.write(marker) { + log::warn!("GPU startup marker: {e}"); + } + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); @@ -228,15 +365,7 @@ fn main() -> eframe::Result { mut launch_options, } = launch; - // The log file lives under the settings directory; opened after the arguments, so `--version` - // and usage errors leave no file behind. Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, services::config_dir()) { - match logger.attach_dir(&dir.join("logs")) { - Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()), - // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. - Err(e) => log::warn!("no log file: {e}"), - } - } + attach_log_file(logger); // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1; @@ -244,114 +373,24 @@ fn main() -> eframe::Result { launch_options.push(launch_args::PerProcess::GpuAdapterEnv); } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs - // become tabs in its window. Only with the per-user settings folder (a portable copy's may be - // shared between users), and not for automation, `--new-instance` or per-process options. + // become tabs in its window. Lives until the event loop returns, then `shutdown` lets the next + // launch become the primary. #[cfg(not(target_os = "macos"))] - let single_instance = { - let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); - // A variable documented as the equivalent of a per-launch flag counts as that flag: a - // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it - // keeps its own process instead of handing its files over and being ignored. - // Folded in here, inside the block that only non-macOS builds compile, so the binding - // above keeps its `mut` off macOS, where nothing is folded in. - let mut launch_options = launch_options; - launch_options.extend_from_slice(&single_instance::env_launch_options()); - let context = single_instance::LaunchContext { - per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, - new_instance_flag: new_instance, - new_instance_env: new_instance_env.as_deref(), - control: control_port.is_some(), - unreadable_paths: !unreadable_paths.is_empty(), - launch_options: &launch_options, - }; - match (single_instance::skip_reason(&context), services::config_dir()) { - (Some(reason), _) => { - log::info!("running as a separate PhotoCraft: {reason}"); - None - } - (None, None) => None, - (None, Some(dir)) => { - let paths: Vec = files.iter().map(|f| single_instance::absolute(f)).collect(); - match single_instance::claim(&dir, &paths) { - single_instance::Launch::Forwarded => { - log::info!("handed {} file(s) to the running PhotoCraft", paths.len()); - return Ok(()); - } - single_instance::Launch::Primary(primary) => Some(primary), - single_instance::Launch::Standalone(reason) => { - log::warn!("starting a separate PhotoCraft: {reason}"); - None - } - } - } - } + let single_instance = match claim_single_instance(&files, new_instance, control_port.is_some(), !unreadable_paths.is_empty(), launch_options) { + std::ops::ControlFlow::Break(()) => return Ok(()), + std::ops::ControlFlow::Continue(primary) => primary, }; - // Lives until the event loop returns, then `shutdown` lets the next launch become the primary. #[cfg(not(target_os = "macos"))] let single_instance = &single_instance; #[cfg(target_os = "macos")] let _ = (new_instance, &launch_options); - // The display server to open the window on: Xwayland for a pen on Wayland, which gives this - // app no pen input (#639). #[cfg(target_os = "linux")] let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok()); #[cfg(target_os = "linux")] - let session = tablet::display_session(); - // Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too, - // where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can - // run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own. - #[cfg(target_os = "linux")] - let session = if session == linux_libs::DisplaySession::Wayland - && gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11 - && std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) - && linux_libs::available(linux_libs::DisplaySession::X11) - { - eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)"); - linux_libs::DisplaySession::X11 - } else { - session - }; + let session = linux_display_session(); - // winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when - // one is missing (issue #201). Name the package to install and exit instead. - #[cfg(target_os = "linux")] - if let Err(message) = linux_libs::preflight(session) { - eprint!("{message}"); - std::process::exit(1); - } - - let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref())); - if let Some(warning) = &control_pref_warning { - eprint!("{warning}"); - } - let control = if let Some(port) = control_port { - let (supplied, token_file) = (auth.control_token, auth.control_token_file); - let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { - Ok(token) => token, - Err(e) => { - eprintln!("photocraft: cannot configure control authentication: {e}"); - std::process::exit(2); - } - }; - if let Some(path) = token_file { - eprintln!("photocraft: control token file: {}", path.display()); - } else if supplied.is_none() { - eprintln!("photocraft: control token: {token}"); - } else { - eprintln!("photocraft: using supplied control token"); - } - let workspace = match automation.workspace() { - Ok(workspace) => workspace, - Err(error) => { - eprintln!("photocraft: cannot configure automation workspace: {error}"); - std::process::exit(2); - } - }; - Some((port, token, workspace)) - } else { - None - }; + let control = control_setup(control_port, auth, &automation); // Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that // launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -409,16 +448,7 @@ fn main() -> eframe::Result { log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or("")); } let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel)); - if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() { - let backend = match &plan.env { - Some(v) => format!("env:{v}"), - None => plan.backend.name().to_string(), - }; - let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() }; - if let Err(e) = s.write(marker) { - log::warn!("GPU startup marker: {e}"); - } - } + write_start_marker(&sentinel, &plan); let gpu_note: std::sync::Arc>> = Default::default(); // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. let wanted_adapter = diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 47b4bbd34a..48d78aa922 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -18,10 +18,10 @@ mod recovery_tests; /// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", - "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", - "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", - "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "dib", "tga", + "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pnm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", + "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", + "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -54,14 +54,14 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[ ("TIFF", &["tif", "tiff"]), ("WebP", &["webp"]), ("GIF", &["gif"]), - ("BMP", &["bmp"]), + ("BMP", &["bmp", "dib"]), ("Targa", &["tga"]), ("ICO", &["ico"]), ("QOI", &["qoi"]), ("PCX", &["pcx"]), ("DDS", &["dds"]), ("XPM", &["xpm"]), - ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]), + ("Portable Bitmap", &["pbm", "pgm", "ppm", "pnm", "pam", "pfm"]), ("OpenEXR", &["exr"]), ("Radiance HDR", &["hdr"]), ("FITS", &["fits", "fit", "fts"]), @@ -91,15 +91,19 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("PhotoCraft", &["pcraft"]), ("OpenRaster", &["ora"]), ("PNG", &["png"]), - ("JPEG", &["jpg"]), + ("JPEG", &["jpg", "jpeg"]), ("WebP", &["webp"]), - ("TIFF", &["tif"]), + ("TIFF", &["tif", "tiff"]), ("Targa", &["tga"]), // Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha. - ("BMP", &["bmp"]), + ("BMP", &["bmp", "dib"]), ("GIF", &["gif"]), ("ICO", &["ico"]), ("OpenEXR", &["exr"]), + // The encoder picks P5, P6, P7 or PFM from the image, not from the name, so only the neutral + // .pnm is offered: a .pbm or .pgm name would hold pixmap data (CodeRabbit on #9775). + ("Netpbm", &["pnm"]), + ("Radiance HDR", &["hdr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327). @@ -112,9 +116,21 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ /// never swaps their extension for a document format's. const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])]; +/// `suggested` with a Netpbm subtype's extension (an opened .pgm) as the neutral .pnm, the name +/// every save dialog starts from: the encoder picks P5, P6, P7 or PFM from the image, so the old +/// suffix could name other data, and macOS and rfd keep the suggested name as it is. +fn netpbm_as_pnm(suggested: &str) -> String { + let path = Path::new(suggested); + let subtype = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| ["pbm", "pgm", "ppm", "pam", "pfm"].contains(&e.as_str())); + if subtype { path.with_extension("pnm").to_string_lossy().into_owned() } else { suggested.to_string() } +} + /// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd. fn save_filters(suggested: &str) -> Vec<(String, Vec)> { let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + // A Netpbm subtype's name (an opened .pgm) starts on the Netpbm type as .pnm: the encoder picks + // the subtype from the image, so the old name could describe other data (CodeRabbit on #9775). + let ext = if ["pbm", "pgm", "ppm", "pam", "pfm"].contains(&ext.as_str()) { "pnm".to_string() } else { ext }; if let Some((name, exts)) = OTHER_SAVE_FILTERS.iter().find(|(_, exts)| exts.contains(&ext.as_str())) { return vec![(name.to_string(), exts.iter().map(|e| e.to_string()).collect())]; } @@ -216,6 +232,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) }) } FileDialogRequest::Save { suggested } => { + let suggested = netpbm_as_pnm(&suggested); let filters = save_filters(&suggested); let name = dialog_file_name(&suggested, cfg!(windows)); let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf); @@ -543,6 +560,61 @@ fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option Option<(u32, u32, Vec)> { + if u64::try_from(text.len()).ok()? > MAX_CLIPBOARD_SVG_BYTES || !text.trim_start_matches('\u{feff}').trim_start().starts_with('<') { + return None; + } + crate::crash_guard::guard("Paste", || photocraft_io::svg::rasterize_rgba8(text.as_bytes()).map_err(|e| e.to_string())).ok() +} + +/// What a paste reads from the OS clipboard, in this order: the first copied file that is an +/// image (Finder also puts the file's icon on the clipboard as an image, which would otherwise +/// paste instead of the file), the clipboard's image, then a drawing copied as SVG markup. Each +/// read runs only when the ones before it found nothing. A read that fails, such as one of a busy +/// clipboard, returns its error, and the reads after it don't run. +fn clipboard_image( + clipboard: &mut C, + files: impl FnOnce(&mut C) -> Result>, String>, + image: impl FnOnce(&mut C) -> Result)>, String>, + text: impl FnOnce(&mut C) -> Result, String>, +) -> Result)>, String> { + if let Some(img) = files(clipboard)?.and_then(|paths| image_from_files(&paths)) { + return Ok(Some(img)); + } + if let Some(img) = image(clipboard)? { + return Ok(Some(img)); + } + Ok(text(clipboard)?.and_then(|text| image_from_svg_text(&text))) +} + +/// One OS clipboard read as [`clipboard_image`] takes it: no content in the format asked for is +/// `None`, and any other failure, such as a busy clipboard or a broken pipe on Wayland, is the +/// read's error. +fn clipboard_read(read: Result) -> Result, String> { + match read { + Ok(value) => Ok(Some(value)), + Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +/// Clipboard text as [`clipboard_image`] takes it: like [`clipboard_read`], except that text that +/// isn't UTF-8 is `None`, like other text that isn't SVG markup. +fn clipboard_text(read: Result) -> Result, String> { + match read { + Err(arboard::Error::ConversionFailure) => Ok(None), + read => clipboard_read(read), + } +} + /// The shell command that starts this install under XWayland, where winit delivers file drops /// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland /// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH. @@ -660,30 +732,24 @@ pub fn native(automation: Option) -> slot.insert(opened.map_err(|e| e.to_string())?) } }; - // Copied files first: Finder also puts the file's icon on the clipboard as an - // image, which would otherwise paste instead of the file. - match cb.get().file_list() { - Ok(paths) => { - if let Some(img) = image_from_files(&paths) { - return Ok(Some(img)); + clipboard_image( + cb, + |cb| clipboard_read(cb.get().file_list()), + |cb| match cb.get_image() { + Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))), + Err(error) => { + #[cfg(target_os = "linux")] + if let Some(image) = wayland_clipboard_image() { + return Ok(Some(image)); + } + match error { + arboard::Error::ContentNotAvailable => Ok(None), + _ => Err(error.to_string()), + } } - } - Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()), - Err(_) => {} - } - match cb.get_image() { - Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))), - Err(error) => { - #[cfg(target_os = "linux")] - if let Some(image) = wayland_clipboard_image() { - return Ok(Some(image)); - } - match error { - arboard::Error::ContentNotAvailable => Ok(None), - _ => Err(error.to_string()), - } - } - } + }, + |cb| clipboard_text(cb.get_text()), + ) }) }), clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs index f9bce2a367..c71074572e 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -294,9 +294,33 @@ fn save_as_offers_only_writable_formats_including_ico() { assert!(format.caps().write, "{name}: .{ext} can't be written"); } } - for ext in ["ico", "gif", "bmp", "dds"] { + for ext in ["ico", "gif", "bmp", "dds", "pnm", "hdr", "dib", "jpeg", "tiff"] { assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); } + // The codec writes P5/P6/P7/PF from the image, never from the name, so Netpbm offers only the + // neutral .pnm (#3609; CodeRabbit on #9775): a .pbm, .pgm or .pfm name would hold other data. + let netpbm = save_filters("Untitled").into_iter().find(|(name, _)| name == "Netpbm").map(|(_, exts)| exts); + assert_eq!(netpbm, Some(vec!["pnm".to_string()])); + // The suggested name itself becomes .pnm, as macOS and rfd keep it (CodeRabbit on #9775). + assert_eq!(netpbm_as_pnm("scans/image.pgm"), Path::new("scans/image.pnm").to_string_lossy()); + assert_eq!(netpbm_as_pnm("image.PFM"), "image.pnm"); + assert_eq!(netpbm_as_pnm("image.png"), "image.png"); + assert_eq!(netpbm_as_pnm("Untitled-1"), "Untitled-1"); + // Also when Save As starts from a subtype's name: no filter offers that subtype again. + for name in ["image.pbm", "image.pgm", "image.ppm", "image.pam", "image.pfm"] { + let filters = save_filters(name); + assert_eq!(filters[0], ("Netpbm".to_string(), vec!["pnm".to_string()]), "{name}"); + let subtype = name.rsplit('.').next().unwrap_or_default(); + assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered"); + } + // File › Open lists every file Save As writes, also in that format's own type (PDF is written + // but not read yet). + for (name, exts) in SAVE_FILTERS { + for ext in exts.iter().filter(|e| **e != "pdf") { + assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}"); + assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}"); + } + } assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); // What Save As writes there: an icon up to 256×256, and a clear error past that. let doc = |side: u32| { @@ -307,6 +331,11 @@ fn save_as_offers_only_writable_formats_including_ico() { let opts = export_options(&photocraft_ui_egui::ExportSettings::default()); let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap(); assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header"); + // And what the Netpbm and Radiance rows write. + let pnm = photocraft_io::export(&doc(4), "x.pnm", &opts).unwrap(); + assert_eq!(pnm.bytes[..2], *b"P6", "an RGB document saves as a binary pixmap"); + let hdr = photocraft_io::export(&doc(4), "x.hdr", &opts).unwrap(); + assert!(hdr.bytes.starts_with(b"#?RADIANCE"), "a Radiance header"); let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string(); assert!(err.contains("256"), "{err}"); } @@ -660,6 +689,175 @@ fn tga_is_trusted_only_with_its_extension() { assert!(image_from_files(&[bin]).is_none()); } +/// A 121 × 81 white rectangle with a 1-unit black stroke, written as markup the way a vector +/// app's Copy puts it on the clipboard as text. +const COPIED_RECTANGLE: &str = r##" + + + +"##; + +/// A 40 × 20 drawing whose left half is a red square; the right half has no art. +const HALF_RED: &str = r##""##; + +/// The clipboard as `clipboard_image` reads it when it holds only `text`. +fn from_text(text: &str) -> Option<(u32, u32, Vec)> { + clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails") +} + +fn pixel(img: &(u32, u32, Vec), x: u32, y: u32) -> [u8; 4] { + let at = ((y * img.0 + x) * 4) as usize; + img.2[at..at + 4].try_into().unwrap() +} + +#[test] +fn svg_markup_copied_as_text_pastes_its_pixels() { + let img = from_text(COPIED_RECTANGLE).expect("the drawing's pixels"); + assert_eq!((img.0, img.1, img.2.len()), (121, 81, 121 * 81 * 4), "one pixel per SVG unit"); + assert_eq!(pixel(&img, 60, 40), [255, 255, 255, 255], "the white fill"); + for (x, y) in [(0, 40), (120, 40), (60, 0), (60, 80)] { + let [r, g, b, a] = pixel(&img, x, y); + assert!(a == 255 && r < 8 && g < 8 && b < 8, "the black stroke at {x},{y}: {:?}", [r, g, b, a]); + } + let img = from_text(HALF_RED).expect("the drawing's pixels"); + assert_eq!((img.0, img.1), (40, 20)); + assert_eq!(pixel(&img, 5, 10), [255, 0, 0, 255]); + assert_eq!(pixel(&img, 30, 10), [0, 0, 0, 0], "transparent where there is no art"); +} + +#[test] +fn text_that_is_not_a_drawing_pastes_nothing() { + for text in ["", "hello", "

not a drawing

", ""] { + assert!(from_text(text).is_none(), "{text:?}"); + } + // Markup that doesn't parse, and markup past the read limit, paste nothing either. + assert!(from_text("").is_none()); + let mut huge = String::from(""); + huge.push_str(&" ".repeat(usize::try_from(MAX_CLIPBOARD_SVG_BYTES).unwrap())); + huge.push_str(""); + assert!(from_text(&huge).is_none(), "markup past the limit isn't parsed"); +} + +#[test] +fn svg_clipboard_text_accepts_a_long_xml_preamble() { + let padding = "x".repeat(8192); + for prefix in [format!(""), format!("\n"), format!("")] { + let text = format!("{prefix}{HALF_RED}"); + let img = from_text(&text).expect("SVG after the XML preamble"); + assert_eq!((img.0, img.1), (40, 20)); + assert_eq!(pixel(&img, 5, 10), [255, 0, 0, 255]); + assert_eq!(pixel(&img, 30, 10), [0, 0, 0, 0]); + } + assert!(from_text(&format!("")).is_none()); + assert!(from_text(&format!("")).is_none()); +} + +#[test] +fn a_drawing_past_the_pixel_limit_pastes_scaled_down() { + let wide = r##""##; + let img = from_text(wide).expect("scaled down, not dropped"); + let pixels = u64::from(img.0) * u64::from(img.1); + assert!(pixels <= 24_000_000 && pixels > 12_000_000, "{} × {}", img.0, img.1); + assert!(img.0.abs_diff(img.1 * 2) <= 2, "the proportions are kept: {} × {}", img.0, img.1); + assert_eq!(pixel(&img, img.0 / 2, img.1 / 2), [0, 0, 255, 255]); +} + +/// Copied files and an image come before SVG text, which is read only when neither is there. +#[test] +fn copied_files_and_images_come_before_svg_text() { + let dir = temp("svg-order"); + let png = dir.join("red.png"); + std::fs::write(&png, red_png(3, 2)).unwrap(); + let blue = (2, 2, [0u8, 0, 255, 255].repeat(4)); + let mut text_read = false; + let svg_text = |read: &mut bool| { + *read = true; + Ok(Some(COPIED_RECTANGLE.to_string())) + }; + let got = clipboard_image(&mut text_read, |_| Ok(Some(vec![png.clone()])), |_| Ok(Some(blue.clone())), svg_text); + assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((3, 2)), "the copied file"); + let got = clipboard_image(&mut text_read, |_| Ok(None), |_| Ok(Some(blue.clone())), svg_text); + assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((2, 2)), "the image, not the SVG text"); + assert!(!text_read, "the text isn't read while there is a file or an image"); + // A copied file that isn't an image (an .svg) falls through to the image, then the text. + let svg = dir.join("drawing.svg"); + std::fs::write(&svg, COPIED_RECTANGLE).unwrap(); + let got = clipboard_image(&mut (), |_| Ok(Some(vec![svg.clone()])), |_| Ok(None), |_| Ok(Some(COPIED_RECTANGLE.to_string()))); + assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((121, 81))); +} + +/// A failed read, such as one of a busy clipboard, is the paste's error: the reads after it, +/// the SVG text included, don't run. +#[test] +fn a_failed_clipboard_read_is_an_error_and_the_text_is_not_read() { + fn busy() -> Result { + Err("the clipboard is busy".to_string()) + } + let mut text_read = false; + let svg_text = |read: &mut bool| { + *read = true; + Ok(Some(COPIED_RECTANGLE.to_string())) + }; + let got = clipboard_image(&mut text_read, |_| busy(), |_| Ok(None), svg_text); + assert_eq!(got, Err("the clipboard is busy".to_string()), "the file list's error"); + let got = clipboard_image(&mut text_read, |_| Ok(None), |_| busy(), svg_text); + assert_eq!(got, Err("the clipboard is busy".to_string()), "the image's error"); + assert!(!text_read, "the text isn't read after a failed read"); + let got = clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| busy()); + assert_eq!(got, Err("the clipboard is busy".to_string()), "the text's error"); +} + +/// Only a clipboard with nothing in the format asked for reads as nothing to paste. A read +/// that fails in any other way, such as a broken pipe on Wayland, is the paste's error, for +/// the copied files and the text alike. Text that isn't UTF-8 reads as no SVG markup. +#[test] +fn only_missing_content_reads_as_nothing_to_paste() { + use arboard::Error; + let failures = || [Error::ClipboardOccupied, Error::ClipboardNotSupported, Error::Unknown { description: "broken pipe".to_string() }]; + assert_eq!(clipboard_read(Ok::<_, Error>(vec![PathBuf::from("a.png")])), Ok(Some(vec![PathBuf::from("a.png")]))); + assert_eq!(clipboard_read::>(Err(Error::ContentNotAvailable)), Ok(None)); + for error in failures().into_iter().chain([Error::ConversionFailure]) { + let message = error.to_string(); + assert_eq!(clipboard_read::>(Err(error)), Err(message.clone()), "{message}"); + } + assert_eq!(clipboard_text(Ok("words".to_string())), Ok(Some("words".to_string()))); + assert_eq!(clipboard_text(Err(Error::ContentNotAvailable)), Ok(None)); + assert_eq!(clipboard_text(Err(Error::ConversionFailure)), Ok(None), "text that isn't UTF-8"); + for error in failures() { + let message = error.to_string(); + assert_eq!(clipboard_text(Err(error)), Err(message.clone()), "{message}"); + } +} + +/// Edit › Paste with SVG markup as the clipboard's only content adds a new layer that holds the +/// drawing's art; other text is still a quiet "nothing to paste". +#[test] +fn paste_turns_svg_text_on_the_clipboard_into_a_layer() { + let text: Rc> = Rc::new(RefCell::new(HALF_RED.to_string())); + let clip = text.clone(); + let services = Services { + clipboard_get_image: Some(Box::new(move || clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(clip.borrow().clone()))))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + let r = photocraft_ui_egui::menus::invoke(&mut app, &ctx, "edit.paste", json!({})).unwrap(); + assert_ne!(r["pasted"], json!(false), "{r}"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers + 1); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let bounds = surface.content_bounds(); + assert_eq!((bounds.width(), bounds.height()), (20, 20), "the red half; the transparent half adds nothing"); + *text.borrow_mut() = "just words".to_string(); + let r = photocraft_ui_egui::menus::invoke(&mut app, &ctx, "edit.paste", json!({})).unwrap(); + assert_eq!(r["pasted"], json!(false)); + assert_eq!(app.ui.status, "Nothing to paste: the clipboard holds no image"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); +} + /// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is /// the paste's error in the status bar, not a crash: the document stays and the next paste /// works. The same for a panic while writing it. diff --git a/assets/icons/font-similar.svg b/assets/icons/font-similar.svg new file mode 100644 index 0000000000..e2399f7ab0 --- /dev/null +++ b/assets/icons/font-similar.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/font-variable.svg b/assets/icons/font-variable.svg new file mode 100644 index 0000000000..4415bcd32a --- /dev/null +++ b/assets/icons/font-variable.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index e9e9521ea6..11ce2f4549 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -38,13 +38,13 @@ The release packages ship `photocraft-cli` alongside the desktop app, so no buil | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\PhotoCraft\photocraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe` by default (`C:\Program Files\ArtCraft\PhotoCraft\photocraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/photocraft-cli` | | macOS | the separate `photocraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add photocraft -- "C:\Program Files\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root --automation-write-root +# Windows (PowerShell), default install folder +claude mcp add photocraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root --automation-write-root # Linux, or macOS with the CLI unzipped onto PATH claude mcp add photocraft -- photocraft-cli mcp --automation-read-root --automation-write-root ``` diff --git a/clippy.toml b/clippy.toml index 4b6c79f555..3522a15b69 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,7 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] +cognitive-complexity-threshold = 30 diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 76ffb69f7d..96fdbd706c 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -332,7 +332,7 @@ fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { } fn inflate(data: &[u8], size: usize) -> Result, Error> { - read_bounded(flate2::read::ZlibDecoder::new(data), size) + read_bounded(flate2::bufread::ZlibDecoder::new(data), size) } fn unzstd(data: &[u8], size: usize) -> Result, Error> { diff --git a/crates/algo/examples/bench_filters2.rs b/crates/algo/examples/bench_filters2.rs index f6b34aca28..3b9aacae24 100644 --- a/crates/algo/examples/bench_filters2.rs +++ b/crates/algo/examples/bench_filters2.rs @@ -4,7 +4,10 @@ //! //! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`. //! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`. -use photocraft_algo::*; +use photocraft_algo::{ + BlurPath, BlurQuality, BlurShape, DepthSource, Distribution, FieldPin, FilterParams, IrisPin, Light, SmartBlurMode, SpinPin, TextureChannel, apply, + output_area, +}; use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_raster::Surface; diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 1cca6c9468..ca346a1f05 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -555,13 +555,20 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx) res } -/// Applies one gallery effect to the working window in place. +/// Applies one gallery effect to the working window in place. Each category handles its own +/// filters and reports whether the effect was one of them. fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { + let handled = + apply_artistic(e, win) || apply_brush_strokes(e, win) || apply_distort(e, win) || apply_sketch(e, win, ctx) || apply_stylize_texture(e, win, ctx); + debug_assert!(handled, "every gallery filter belongs to a category"); +} + +/// Artistic filters: the painterly looks of the gallery. +fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool { let g = |k: &str| e.get(k); let (w, h) = (win.w, win.h); let (fg, bg) = (rgb(e.foreground), rgb(e.background)); match e.filter { - // ---------------- Artistic ---------------- F::ColoredPencil => { let pw = g("pencilWidth"); let pressure = g("strokePressure") / 15.0; @@ -745,7 +752,16 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { win.c[i] = map(saturate(win.c[i], 1.2), |v| clamp01(v * k + n).powf(1.1 + sh * 0.6)); } } - // ---------------- Brush Strokes ---------------- + _ => return false, + } + true +} + +/// Brush Strokes filters. +fn apply_brush_strokes(e: &GalleryEffect, win: &mut Win) -> bool { + let g = |k: &str| e.get(k); + let (w, h) = (win.w, win.h); + match e.filter { F::AccentedEdges => { let mut l = win.lum(); blur1(&mut l, w, h, 0.4 + g("smoothness") * 0.3); @@ -859,7 +875,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { *p = map(*p, |v| clamp01((v - 0.5) * (1.0 + con) + 0.5) * (1.0 - ink * 0.9 * (0.5 + con))); } } - // ---------------- Distort ---------------- + _ => return false, + } + true +} + +/// Distort filters. +fn apply_distort(e: &GalleryEffect, win: &mut Win) -> bool { + let g = |k: &str| e.get(k); + let (w, h) = (win.w, win.h); + let bg = rgb(e.background); + match e.filter { F::DiffuseGlow => { let (gr, ga, ca) = (g("graininess") / 10.0, g("glowAmount") / 20.0, g("clearAmount") / 20.0); let thr = 0.2 + ca * 0.7; @@ -903,7 +929,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy); } } - // ---------------- Sketch ---------------- + _ => return false, + } + true +} + +/// Sketch filters (foreground is ink, background is paper). +fn apply_sketch(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool { + let g = |k: &str| e.get(k); + let (w, h) = (win.w, win.h); + let (fg, bg) = (rgb(e.foreground), rgb(e.background)); + match e.filter { F::BasRelief => { let mut l = win.lum(); let orig = l.clone(); @@ -1107,7 +1143,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { win.c[i] = map(win.c[i], |v| (v * (0.85 + 0.25 * fib) - 0.5) * (co * 1.6 + 0.2) + 0.5 + (br - 0.5) * 0.8); } } - // ---------------- Stylize ---------------- + _ => return false, + } + true +} + +/// Stylize and Texture filters. +fn apply_stylize_texture(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool { + let g = |k: &str| e.get(k); + let (w, h) = (win.w, win.h); + let (fg, bg) = (rgb(e.foreground), rgb(e.background)); + match e.filter { F::GlowingEdges => { blur3(&mut win.c, w, h, 0.3 + g("smoothness") * 0.25); let mut e3 = vec![[0.0f32; 3]; win.c.len()]; @@ -1123,7 +1169,6 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { *p = map(*q, |v| clamp01(v * k)); } } - // ---------------- Texture ---------------- F::Craquelure => { let (sp, dep, bri) = (g("crackSpacing"), g("crackDepth") / 10.0, g("crackBrightness") / 10.0); let cell = sp * 1.3 + 1.0; @@ -1239,7 +1284,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { } } F::Texturizer => texturize(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert")), + _ => return false, } + true } fn normalize3(v: [f32; 3]) -> [f32; 3] { diff --git a/crates/algo/src/blur/motion_fft.rs b/crates/algo/src/blur/motion_fft.rs index 9da1b1a1b7..a0bf9dce6a 100644 --- a/crates/algo/src/blur/motion_fft.rs +++ b/crates/algo/src/blur/motion_fft.rs @@ -361,23 +361,7 @@ impl Plan { { return None; } - let mut scales = [0.0f64; 5]; - for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() { - if i % 4096 == 0 && ctl.cancelled() { - return None; - } - if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE) - || (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value))) - { - return None; - } - let a = if alpha { *pixel.last()? } else { 1.0 }; - for (c, &value) in pixel.iter().enumerate() { - let value = if alpha && c + 1 < src.ch { value * a } else { value }; - let scale = scales.get_mut(c)?; - *scale = scale.max(f64::from(value.abs())); - } - } + let scales = channel_scales(src, alpha, ctl)?; let mut result = buffer(ow.checked_mul(oh)?.checked_mul(src.ch)?, 0.0f64)?; let read = self.read_rect(out)?; let used = read.intersect(&src.rect); @@ -393,81 +377,18 @@ impl Plan { if ctl.cancelled() { return None; } + let pair = ChannelPair { src, alpha, scales: &scales, channel }; data.fill(Number::default()); - for y in used.y0..used.y1 { - if (y - used.y0) % 8 == 0 && ctl.cancelled() { - return None; - } - let source_start = - usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?; - let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?; - let row = src.data.get(source_start..source_start.checked_add(source_len)?)?; - let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?; - let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?; - for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) { - let a = if alpha { *pixel.last()? } else { 1.0 }; - // Normalize each channel separately before packing a pair. - // Otherwise a bright HDR colour's FFT residual could swamp - // a small alpha channel sharing the imaginary component. - let sample = |c: usize| -> f64 { - match (pixel.get(c), scales.get(c)) { - (Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale, - _ => 0.0, - } - }; - *dst = Number::new(sample(channel), sample(channel + 1)); - } - } + self.pack_channel_pair(&pair, used, read, &mut data, ctl)?; self.transform(&mut data, &mut transposed, &mut scratch, false, ctl)?; for (value, kernel) in data.iter_mut().zip(&self.kernel) { *value *= kernel; } self.transform(&mut data, &mut transposed, &mut scratch, true, ctl)?; - let x0 = usize::try_from(-self.min_x).ok()?; - let y0 = usize::try_from(-self.min_y).ok()?; - for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() { - let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?; - let values = data.get(start..start.checked_add(ow)?)?; - for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) { - *pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?; - if let Some(next) = pixel.get_mut(channel + 1) { - *next = value.im * norm * scales.get(channel + 1)?; - } - } - } + self.unpack_channel_pair(&pair, &data, &mut result, ow, norm)?; } if alpha { - // The row kernel accumulates in f32. Recompute very small alpha - // near its cutoff so a different summation order cannot expose colour. - let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?; - for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() { - if i % 16 == 0 && ctl.cancelled() { - return None; - } - let a = pixel.last_mut()?; - // Valid alpha is nonnegative. Remove transform roundoff at - // zero to keep fully transparent regions empty. - if a.abs() < 1e-14 * scales.get(src.ch - 1)? { - *a = 0.0; - } - *a = a.clamp(0.0, 1.0); - let a = *a; - // The row kernel's f32 sum can straddle the cutoff at long distances. - // Keep its branch and straight colour exactly for that narrow - // range, rather than amplifying a tiny alpha difference. - if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) { - let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?; - let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?; - let original = sample_row_point(src, x, y, alpha, &self.taps, ctl)?; - for (dst, value) in pixel.iter_mut().zip(original) { - *dst = f64::from(value); - } - continue; - } - for value in pixel.iter_mut().take(src.ch - 1) { - *value = if a as f32 > 1e-7 { *value / a } else { 0.0 }; - } - } + self.restore_small_alpha(src, out, ow, &scales, &mut result, ctl)?; } let mut output = buffer(result.len(), 0.0f32)?; for (dst, value) in output.iter_mut().zip(result) { @@ -475,6 +396,125 @@ impl Plan { } Some(output) } + + /// Packs the pair of channels into the real and imaginary parts of `data`, over the source + /// rows that fall inside the transform tile, each channel normalised by its own scale. + fn pack_channel_pair(&self, pair: &ChannelPair, used: Rect, read: Rect, data: &mut [Number], ctl: &Interrupt) -> Option<()> { + let ChannelPair { src, alpha, scales, channel } = *pair; + let (sw, _) = dimensions(src.rect)?; + for y in used.y0..used.y1 { + if (y - used.y0) % 8 == 0 && ctl.cancelled() { + return None; + } + let source_start = + usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?; + let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?; + let row = src.data.get(source_start..source_start.checked_add(source_len)?)?; + let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?; + let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?; + for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) { + let a = if alpha { *pixel.last()? } else { 1.0 }; + // Normalize each channel separately before packing a pair. + // Otherwise a bright HDR colour's FFT residual could swamp + // a small alpha channel sharing the imaginary component. + let sample = |c: usize| -> f64 { + match (pixel.get(c), scales.get(c)) { + (Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale, + _ => 0.0, + } + }; + *dst = Number::new(sample(channel), sample(channel + 1)); + } + } + Some(()) + } + + /// Writes the filtered pair of channels from `data` into the output pixels, undoing the + /// per-channel normalisation. + fn unpack_channel_pair(&self, pair: &ChannelPair, data: &[Number], result: &mut [f64], ow: usize, norm: f64) -> Option<()> { + let ChannelPair { src, scales, channel, .. } = *pair; + let x0 = usize::try_from(-self.min_x).ok()?; + let y0 = usize::try_from(-self.min_y).ok()?; + for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() { + let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?; + let values = data.get(start..start.checked_add(ow)?)?; + for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) { + *pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?; + if let Some(next) = pixel.get_mut(channel + 1) { + *next = value.im * norm * scales.get(channel + 1)?; + } + } + } + Some(()) + } + + /// Straightens the colour of the filtered pixels by their alpha, taking care of the pixels + /// whose alpha is at the cutoff. + fn restore_small_alpha(&self, src: &Image, out: Rect, ow: usize, scales: &[f64; 5], result: &mut [f64], ctl: &Interrupt) -> Option<()> { + // The row kernel accumulates in f32. Recompute very small alpha + // near its cutoff so a different summation order cannot expose colour. + let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?; + for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() { + if i % 16 == 0 && ctl.cancelled() { + return None; + } + let a = pixel.last_mut()?; + // Valid alpha is nonnegative. Remove transform roundoff at + // zero to keep fully transparent regions empty. + if a.abs() < 1e-14 * scales.get(src.ch - 1)? { + *a = 0.0; + } + *a = a.clamp(0.0, 1.0); + let a = *a; + // The row kernel's f32 sum can straddle the cutoff at long distances. + // Keep its branch and straight colour exactly for that narrow + // range, rather than amplifying a tiny alpha difference. + if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) { + let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?; + let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?; + let original = sample_row_point(src, x, y, true, &self.taps, ctl)?; + for (dst, value) in pixel.iter_mut().zip(original) { + *dst = f64::from(value); + } + continue; + } + for value in pixel.iter_mut().take(src.ch - 1) { + *value = if a as f32 > 1e-7 { *value / a } else { 0.0 }; + } + } + Some(()) + } +} + +/// One pair of channels (packed as real and imaginary parts) of a filter call. +#[derive(Clone, Copy)] +struct ChannelPair<'a> { + src: &'a Image, + alpha: bool, + scales: &'a [f64; 5], + channel: usize, +} + +/// Per-channel peak magnitude of the (alpha-premultiplied) samples. `None` when a sample is not +/// finite, is too large, or its alpha is outside 0..=1, and when cancelled. +fn channel_scales(src: &Image, alpha: bool, ctl: &Interrupt) -> Option<[f64; 5]> { + let mut scales = [0.0f64; 5]; + for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() { + if i % 4096 == 0 && ctl.cancelled() { + return None; + } + if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE) || (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value))) + { + return None; + } + let a = if alpha { *pixel.last()? } else { 1.0 }; + for (c, &value) in pixel.iter().enumerate() { + let value = if alpha && c + 1 < src.ch { value * a } else { value }; + let scale = scales.get_mut(c)?; + *scale = scale.max(f64::from(value.abs())); + } + } + Some(scales) } #[cfg(test)] diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 87b8407a95..c781140ac3 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -167,29 +167,7 @@ fn complete_cow<'a>( } let r = p.patch_radius.max(1); let psz = 2 * r + 1; - // Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the - // structure where the texture has averaged out, and fills flat). A thin hole stops while it - // is still about two patches thick: through it, not along it, is what counts (#1366). - let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }]; - let mut boxes = vec![hb0]; - let mut ext = hb0.width().max(hb0.height()); - let thickness = hole_thickness(w, hb0, px); - let thin = thickness.saturating_mul(THIN_RATIO) <= ext; - if thin { - ext = thickness; - } - let mut exts = vec![ext]; - while let Some(l) = levels.last() { - ctl.check()?; - if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { - break; - } - let d = downsample(l, ch); - levels.push(d); - boxes.push(boxes[boxes.len() - 1].half()); - ext = ext.div_ceil(2); - exts.push(ext); - } + let Pyramid { mut levels, boxes, exts, thin } = build_pyramid(Level { w, h, img, px: Cow::Borrowed(px) }, ch, hb0, psz, ctl)?; let nlev = levels.len(); // The coarser level's result. let mut prev: Option = None; @@ -209,15 +187,7 @@ fn complete_cow<'a>( return Ok(Err(limg)); } // The level's own pixels to fill (at level 0 the caller's, or a copy made in parallel). - let mut limg = match limg { - Cow::Owned(v) => v, - Cow::Borrowed(b) => { - const CHUNK: usize = 1 << 16; - let mut v = vec![0.0f32; b.len()]; - par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()])); - v - } - }; + let mut limg = into_owned_pixels(limg); let targets = targets_around(lw, r, &lpx, hb, tb); ctl.check()?; // Initialise the hole: at the coarsest level peeled from its edge inwards (or a membrane @@ -225,40 +195,13 @@ fn complete_cow<'a>( // its scaled-up matches. let coarser = prev.take(); match &coarser { - Some(c) => { - let (pw, ph) = (c.w, c.h); - for y in hb.y0..hb.y1 { - for x in hb.x0..hb.x1 { - if lpx[y * lw + x] == Px::Hole { - let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); - let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch); - limg[d..d + ch].copy_from_slice(&c.img[s..s + ch]); - } - } - } - } + Some(c) => copy_up_coarser(&mut limg, &lpx, lw, ch, hb, c), // A thin hole, when peeling it would take too long, takes the membrane below: a few // patches thick here, a membrane across it is its surroundings. None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => { onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)? } - None => { - let mut unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); - // Across a thin hole the membrane starts a patch radius out, past the - // anti-aliased fringe or halo that a selection drawn close to the object leaves. - if thin { - let hole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect(); - for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) { - *u |= d <= r as f32; - } - } - let m = membrane_fill(lw, lh, ch, &limg, &unknown); - for (i, k) in lpx.iter().enumerate() { - if *k == Px::Hole { - limg[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]); - } - } - } + None => fill_hole_with_membrane(&mut limg, &lpx, (lw, lh, ch), r, thin), } if sources.len() == 0 { prev = Some(Coarser { w: lw, h: lh, img: limg, field: Vec::new(), tbox: tb }); @@ -290,36 +233,7 @@ fn complete_cow<'a>( // NNF init: the coarser level's matches scaled up where they land on a valid source, // random otherwise. let mut rng = Rng::new(p.seed ^ (li as u64) << 20); - let tw = tb.width(); - let mut nnf = vec![(0i32, 0i32); tb.area()]; - let mut upsampled = false; - for y in tb.y0..tb.y1 { - if y % 32 == 0 { - ctl.check()?; - } - for x in tb.x0..tb.x1 { - let k = (y - tb.y0) * tw + (x - tb.x0); - if !solver.targets[k] { - continue; - } - let mut cand = None; - if let Some(c) = &coarser - && !c.field.is_empty() - { - let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1)); - let (sx, sy) = c.field_at(cx, cy); - if sx != i32::MIN { - // The same displacement, twice as long. - let c = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32)); - if solver.is_valid(c.0, c.1) { - cand = Some(c); - } - } - } - upsampled |= cand.is_some(); - nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw)); - } - } + let (mut nnf, upsampled) = seed_matches(&solver, coarser.as_ref(), &sources, &mut rng, lw, tb, ctl)?; // The coarser level has given all it had. drop(coarser); let mut cost = vec![f32::INFINITY; tb.area()]; @@ -370,5 +284,138 @@ fn complete_cow<'a>( Ok(prev.map(|c| c.img).ok_or(Cow::Owned(Vec::new()))) } +/// The levels of the completion, finest first, with the hole's bounds and extent at each. +struct Pyramid<'a> { + levels: Vec>, + boxes: Vec, + exts: Vec, + /// The hole is a thin one: its thickness, not its length, decides how deep to go. + thin: bool, +} + +/// Shrinks the image until the hole is about four patches across (a deeper pyramid decides the +/// structure where the texture has averaged out, and fills flat). A thin hole stops while it is +/// still about two patches thick: through it, not along it, is what counts (#1366). +fn build_pyramid<'a>( + first: Level<'a>, + ch: usize, + hb0: common::Bounds, + psz: usize, + ctl: &photocraft_raster::Interrupt, +) -> Result, photocraft_raster::Cancelled> { + let thickness = hole_thickness(first.w, hb0, &first.px); + let mut levels = vec![first]; + let mut boxes = vec![hb0]; + let mut ext = hb0.width().max(hb0.height()); + let thin = thickness.saturating_mul(THIN_RATIO) <= ext; + if thin { + ext = thickness; + } + let mut exts = vec![ext]; + while let Some(l) = levels.last() { + ctl.check()?; + if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { + break; + } + let d = downsample(l, ch); + levels.push(d); + boxes.push(boxes[boxes.len() - 1].half()); + ext = ext.div_ceil(2); + exts.push(ext); + } + Ok(Pyramid { levels, boxes, exts, thin }) +} + +/// The level's own pixels to fill: the caller's, or a copy made in parallel. +fn into_owned_pixels(img: Cow<'_, [f32]>) -> Vec { + match img { + Cow::Owned(v) => v, + Cow::Borrowed(b) => { + const CHUNK: usize = 1 << 16; + let mut v = vec![0.0f32; b.len()]; + par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()])); + v + } + } +} + +/// Starts the hole from the coarser level's pixels, scaled up. +fn copy_up_coarser(img: &mut [f32], px: &[Px], w: usize, ch: usize, hb: common::Bounds, c: &Coarser) { + let (pw, ph) = (c.w, c.h); + for y in hb.y0..hb.y1 { + for x in hb.x0..hb.x1 { + if px[y * w + x] == Px::Hole { + let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1)); + let (d, s) = ((y * w + x) * ch, (sy * pw + sx) * ch); + img[d..d + ch].copy_from_slice(&c.img[s..s + ch]); + } + } + } +} + +/// Starts the hole as a membrane over its surroundings, for where nothing can be copied. `dims` +/// is the level's width, height and channel count. +fn fill_hole_with_membrane(img: &mut [f32], px: &[Px], dims: (usize, usize, usize), r: usize, thin: bool) { + let (w, h, ch) = dims; + let mut unknown: Vec = px.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + // Across a thin hole the membrane starts a patch radius out, past the + // anti-aliased fringe or halo that a selection drawn close to the object leaves. + if thin { + let hole: Vec = px.iter().map(|k| *k == Px::Hole).collect(); + for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, w, h)) { + *u |= d <= r as f32; + } + } + let m = membrane_fill(w, h, ch, img, &unknown); + for (i, k) in px.iter().enumerate() { + if *k == Px::Hole { + img[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]); + } + } +} + +/// The coarser level's match for `(x, y)`, scaled up, where it lands on a valid source. +fn upscaled_match(solver: &Solver, c: &Coarser, x: usize, y: usize) -> Option<(i32, i32)> { + let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1)); + let (sx, sy) = c.field_at(cx, cy); + if sx == i32::MIN { + return None; + } + // The same displacement, twice as long. + let m = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32)); + solver.is_valid(m.0, m.1).then_some(m) +} + +/// NNF init: the coarser level's matches scaled up where they land on a valid source, random +/// otherwise. Also says whether any came from the coarser level. +fn seed_matches( + solver: &Solver, + coarser: Option<&Coarser>, + sources: &Sources, + rng: &mut Rng, + lw: usize, + tb: common::Bounds, + ctl: &photocraft_raster::Interrupt, +) -> Result<(Vec<(i32, i32)>, bool), photocraft_raster::Cancelled> { + let tw = tb.width(); + let mut nnf = vec![(0i32, 0i32); tb.area()]; + let mut upsampled = false; + for y in tb.y0..tb.y1 { + if y % 32 == 0 { + ctl.check()?; + } + for x in tb.x0..tb.x1 { + let k = (y - tb.y0) * tw + (x - tb.x0); + if !solver.targets[k] { + continue; + } + let cand = coarser.filter(|c| !c.field.is_empty()).and_then(|c| upscaled_match(solver, c, x, y)); + upsampled |= cand.is_some(); + nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw)); + } + } + Ok((nnf, upsampled)) +} + #[cfg(test)] mod tests; diff --git a/crates/algo/src/inpaint/common.rs b/crates/algo/src/inpaint/common.rs index bbbcc8df26..16989a6079 100644 --- a/crates/algo/src/inpaint/common.rs +++ b/crates/algo/src/inpaint/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Cow; /// Below this many cells a pass runs on the calling thread (thread hand-off costs more than it /// saves). Every pass gives the same result either way. diff --git a/crates/algo/src/inpaint/solver.rs b/crates/algo/src/inpaint/solver.rs index b5cc82915d..67e0e2645e 100644 --- a/crates/algo/src/inpaint/solver.rs +++ b/crates/algo/src/inpaint/solver.rs @@ -1,5 +1,5 @@ use super::common::{Adapt, Bounds, PAR_MIN_PATCHES, Px, Rng}; -use super::*; +use super::par; /// Most channels whose moments the colour adaptation fits (the rest are copied as they are). const MOM_CH: usize = 8; diff --git a/crates/algo/src/inpaint/sources.rs b/crates/algo/src/inpaint/sources.rs index a3f390cfc3..fcc255e49a 100644 --- a/crates/algo/src/inpaint/sources.rs +++ b/crates/algo/src/inpaint/sources.rs @@ -1,5 +1,5 @@ use super::common::{Bounds, Level, PAR_MIN, Px}; -use super::*; +use super::{Cow, par}; /// Half-size level: a cell is a hole if any of its pixels is; else it averages its non-void /// pixels, and may be copied from only if all of them may. diff --git a/crates/algo/src/inpaint/synthesis.rs b/crates/algo/src/inpaint/synthesis.rs index 0c3bf9c375..cd0452ab4c 100644 --- a/crates/algo/src/inpaint/synthesis.rs +++ b/crates/algo/src/inpaint/synthesis.rs @@ -1,7 +1,7 @@ use super::common::{Bounds, Px, Rng}; use super::solver::{Best, LANES, known_sse, known_sse_any, known_sse_lanes, least_sum}; use super::sources::{hole_bbox, integral, window_count}; -use super::*; +use super::{HoleIntegral, membrane_fill, par}; /// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the /// source patch that best matches what is already known around it (the first in `sources` of diff --git a/crates/algo/src/panorama/blend.rs b/crates/algo/src/panorama/blend.rs index 1aaa821c14..034e5ed96d 100644 --- a/crates/algo/src/panorama/blend.rs +++ b/crates/algo/src/panorama/blend.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Normal, Placement, par_map, par_rows}; // ---------- photometric ---------- diff --git a/crates/algo/src/pixelate/cells.rs b/crates/algo/src/pixelate/cells.rs index ebf326fbde..0d88fbc77e 100644 --- a/crates/algo/src/pixelate/cells.rs +++ b/crates/algo/src/pixelate/cells.rs @@ -1,6 +1,6 @@ //! Reuse the jittered lattice in cell space. Raster work stays in the original //! row/candidate order, including f32 ties and boundary-cell average additions. -use super::*; +use super::{Ctx, Image, MAXC, Rect, cell_point, hash01, native, ncol, subtractive}; #[derive(Clone, Copy)] struct Site { @@ -348,76 +348,101 @@ fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f let mut owners = vec![u32::MAX; width]; for (row, pixels) in result.chunks_exact_mut(stride).enumerate() { let y = out.y0.checked_add(i32::try_from(row).ok()?)?; - let fy = y as f32 + 0.5; let cy = key(y, origin.1, cell); depths.fill(-0.5); owners.fill(u32::MAX); if cell >= 32.0 && out.width() <= 2048 { - for &(cx, x0, x1) in &base_runs { - let ids = grid.neighbours(cx, cy)?; - let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; - let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; - certified_span::(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?; - } + certified_row(grid, &base_runs, out, cy, y, &mut owners, &mut depths)?; } else { - for gy in cy.checked_sub(1)?..=cy.checked_add(1)? { - let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?; - // Global gy/gx order is the original dy/dx tie order at every pixel. - for (column, &(x0, x1)) in columns.iter().enumerate() { - if x0 >= x1 { - continue; - } - let i = row.checked_add(column)?; - let site = grid.sites.get(i)?; - let dy2 = (site.point.1 - fy).powi(2); - let reach = f64::from(site.radius) + 1.5; - let square = reach * reach - f64::from(dy2); - if square < 0.0 { - continue; - } - // The extra pixel covers subtraction/square/sqrt rounding for - // the bounded coordinates/radii above. The exact old depth - // comparison still decides every candidate that survives. - let reach_x = square.max(0.0).sqrt() + 1.0; - let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0); - let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1); - if lo >= hi { - continue; - } - let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?; - let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?; - for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { - let fx = (lo + offset as i32) as f32 + 0.5; - let d = ((site.point.0 - fx).powi(2) + dy2).sqrt(); - let candidate = site.radius - d; - if candidate > *depth { - *depth = candidate; - *owner = i as u32; - } - } - } - } - } - for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) { - let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 }; - let mut canvas = bg; - if ctx.alpha { - *canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0)); - } - if owner != u32::MAX { - let i = owner as usize; - let k = (depth + 0.5).clamp(0.0, 1.0); - let dot = grid.sites.get(i)?; - for (c, value) in canvas.iter_mut().enumerate().take(n) { - *value += (*dot.color.get(c)? - *value) * k; - } - } - pixel.copy_from_slice(canvas.get(..n)?); + scan_row(grid, &columns, out, cy, y, &mut owners, &mut depths)?; } + composite_row(pixels, &depths, &owners, grid, ctx, bg)?; } Some(result) } +/// Resolve one output row's dot owners through the certified run spans. +fn certified_row(grid: &Grid, base_runs: &[(i32, i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> { + for &(cx, x0, x1) in base_runs { + let ids = grid.neighbours(cx, cy)?; + let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; + certified_span::(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?; + } + Some(()) +} + +/// Resolve one output row's dot owners by scanning the 3x3 neighbouring cells. +fn scan_row(grid: &Grid, columns: &[(i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> { + let fy = y as f32 + 0.5; + for gy in cy.checked_sub(1)?..=cy.checked_add(1)? { + let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?; + // Global gy/gx order is the original dy/dx tie order at every pixel. + for (column, &span) in columns.iter().enumerate() { + if span.0 >= span.1 { + continue; + } + let i = row.checked_add(column)?; + scan_site(grid, i, fy, span, out, owners, depths)?; + } + } + Some(()) +} + +/// Let one site claim the pixels of the current row it reaches within `[x0, x1)`. +fn scan_site(grid: &Grid, i: usize, fy: f32, (x0, x1): (i32, i32), out: Rect, owners: &mut [u32], depths: &mut [f32]) -> Option<()> { + let site = grid.sites.get(i)?; + let dy2 = (site.point.1 - fy).powi(2); + let reach = f64::from(site.radius) + 1.5; + let square = reach * reach - f64::from(dy2); + if square < 0.0 { + return Some(()); + } + // The extra pixel covers subtraction/square/sqrt rounding for + // the bounded coordinates/radii above. The exact old depth + // comparison still decides every candidate that survives. + let reach_x = square.max(0.0).sqrt() + 1.0; + let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0); + let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1); + if lo >= hi { + return Some(()); + } + let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?; + for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { + let fx = (lo + offset as i32) as f32 + 0.5; + let d = ((site.point.0 - fx).powi(2) + dy2).sqrt(); + let candidate = site.radius - d; + if candidate > *depth { + *depth = candidate; + *owner = i as u32; + } + } + Some(()) +} + +/// Blend each pixel of a row with the background and its owning dot colour. +fn composite_row(pixels: &mut [f32], depths: &[f32], owners: &[u32], grid: &Grid, ctx: &Ctx, bg: [f32; 8]) -> Option<()> { + let n = pixels.len() / depths.len().max(1); + for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(depths).zip(owners) { + let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 }; + let mut canvas = bg; + if ctx.alpha { + *canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0)); + } + if owner != u32::MAX { + let i = owner as usize; + let k = (depth + 0.5).clamp(0.0, 1.0); + let dot = grid.sites.get(i)?; + for (c, value) in canvas.iter_mut().enumerate().take(n) { + *value += (*dot.color.get(c)? - *value) * k; + } + } + pixel.copy_from_slice(canvas.get(..n)?); + } + Some(()) +} + /// The filters sample cell centres, not a dense neighbourhood. On ordinary /// tiles read those sparse samples directly; keep only the original output /// pixels for alpha and selection mixing. Crystallize boundary tiles still diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index 19a4b8218d..cf1585115c 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -1236,256 +1236,320 @@ fn star(out: &mut Vec, c: (f32, f32), r: f32, points: usize, col: [f32; 4] } } -/// Builds picture-frame primitives inside `canvas`. -pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec { - let mut out = Vec::new(); - let short = (canvas.width().min(canvas.height()) as f32).max(8.0); - let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0; - let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05); - let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6); - let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0); - let inset = margin + unit; - let b = Border { - x0: canvas.x0 as f32 + inset, - y0: canvas.y0 as f32 + inset, - w: (canvas.width() as f32 - 2.0 * inset).max(1.0), - h: (canvas.height() as f32 - 2.0 * inset).max(1.0), - }; - let total = b.len(); - let count = (total / spacing).round().max(4.0) as usize; - let step = total / count as f32; - let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64); - let (vine, fl, lf) = (spec.vine_color, spec.flower_color, spec.leaf_color); - let style = FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize]; - let lines = spec.lines.clamp(1, 5) as usize; - let white = [1.0, 1.0, 1.0, 1.0]; - let yellow = [0.98, 0.82, 0.2, 1.0]; - // A wavy vine along the border. - let vine_path = |out: &mut Vec, amp: f32, wl: f32, col: [f32; 4], r: f32| { - let n = (total / 3.0).ceil().max(8.0) as usize; +/// Resolved geometry and palette shared by every picture-frame style. +struct FrameLayout { + b: Border, + unit: f32, + thick: f32, + spacing: f32, + total: f32, + count: usize, + step: f32, + lines: usize, + vine: [f32; 4], + fl: [f32; 4], + lf: [f32; 4], + style: &'static str, +} + +const FRAME_WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const FRAME_YELLOW: [f32; 4] = [0.98, 0.82, 0.2, 1.0]; + +fn wave_off(s: f32, amp: f32, wl: f32) -> f32 { + amp * (s / wl * std::f32::consts::TAU).sin() +} + +impl FrameLayout { + fn new(spec: &FrameSpec, canvas: Rect) -> Self { + let short = (canvas.width().min(canvas.height()) as f32).max(8.0); + let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0; + let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05); + let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6); + let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0); + let inset = margin + unit; + let b = Border { + x0: canvas.x0 as f32 + inset, + y0: canvas.y0 as f32 + inset, + w: (canvas.width() as f32 - 2.0 * inset).max(1.0), + h: (canvas.height() as f32 - 2.0 * inset).max(1.0), + }; + let total = b.len(); + let count = (total / spacing).round().max(4.0) as usize; + Self { + b, + unit, + thick, + spacing, + total, + count, + step: total / count as f32, + lines: spec.lines.clamp(1, 5) as usize, + vine: spec.vine_color, + fl: spec.flower_color, + lf: spec.leaf_color, + style: FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize], + } + } + + /// A wavy vine along the border. + fn vine_path(&self, out: &mut Vec, amp: f32, wl: f32, col: [f32; 4], r: f32) { + let n = (self.total / 3.0).ceil().max(8.0) as usize; let mut prev: Option<(f32, f32)> = None; for k in 0..=n { - let s = k as f32 / n as f32 * total; - let (p, _, nn) = b.at(s); - let o = amp * (s / wl * std::f32::consts::TAU).sin(); + let s = k as f32 / n as f32 * self.total; + let (p, _, nn) = self.b.at(s); + let o = wave_off(s, amp, wl); let q = (p.0 + nn.0 * o, p.1 + nn.1 * o); if let Some(pp) = prev { out.push(Prim::capsule(pp, q, r, r, col, col)); } prev = Some(q); } - }; - let wave_off = |s: f32, amp: f32, wl: f32| amp * (s / wl * std::f32::consts::TAU).sin(); - match style { - "vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => { - let amp = unit * 0.5; - let wl = step * 2.0; - vine_path(&mut out, amp, wl, vine, thick); - for k in 0..count { - let s = k as f32 * step + step * 0.5; - let (p, t, nn) = b.at(s); - let o = wave_off(s, amp, wl); - let q = (p.0 + nn.0 * o, p.1 + nn.1 * o); - let side = if k % 2 == 0 { 1.0 } else { -1.0 }; - let ta = t.1.atan2(t.0); - let la = ta + side * 0.9 + rng.sym() * 0.3; - let ll = unit * rng.range(0.9, 1.2); - match style { - "vineWithFlowers" => { - leaf(&mut out, q, la, ll, lf); - if k % 2 == 0 { - let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side); - flower(&mut out, c, unit * 0.65, 5, fl, yellow, rng.f() * 6.3); - } - } - "vineWithLeaves" => { - leaf(&mut out, q, la, ll, lf); - leaf(&mut out, q, ta - side * 0.9, ll * 0.8, lf); - } - "ivy" => { - for j in 0..3 { - let a = la + (j as f32 - 1.0) * 0.7; - leaf(&mut out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32)); - } - } - "roses" => { - leaf(&mut out, q, la, ll, lf); - if k % 3 == 0 { - let r = unit * 0.7; - for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() { - let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32); - out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55))); - } - } - } - "daisies" => { - leaf(&mut out, q, la, ll * 0.8, lf); - if k % 2 == 1 { - flower(&mut out, q, unit * 0.6, 12, white, yellow, rng.f()); - } - } - _ => { - leaf(&mut out, q, la, ll, lf); - for _ in 0..3 { - let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4); - out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6))); - } - } - } - } - } - "bamboo" => { - for edge in 0..4 { - let (s0, l) = match edge { - 0 => (0.0, b.w), - 1 => (b.w, b.h), - 2 => (b.w + b.h, b.w), - _ => (2.0 * b.w + b.h, b.h), - }; - let (a, _, _) = b.at(s0 + 0.01); - let (z, _, _) = b.at(s0 + l - 0.01); - let joints = (l / (spacing * 2.0)).round().max(1.0) as usize; - for j in 0..joints { - let f0 = j as f32 / joints as f32; - let f1 = (j + 1) as f32 / joints as f32; - let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0); - let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1); - out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8))); - out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7))); - } - // A few leaves per edge. - for _ in 0..(joints / 2).max(1) { - let f = rng.f(); - let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f); - leaf(&mut out, p, rng.f() * 6.3, unit * 1.4, lf); - } - } - } - "waves" | "zigzag" | "doubleLine" | "rope" => { - for li in 0..lines { - let off = (li as f32 - (lines - 1) as f32 / 2.0) * thick * 4.0; - let n = (total / 2.0).ceil().max(8.0) as usize; - let mut prev: Option<(f32, f32)> = None; - for k in 0..=n { - let s = k as f32 / n as f32 * total; - let (p, _, nn) = b.at(s); - let o = off - + match style { - "waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step), - "zigzag" => { - let ph = (s / step).fract(); - unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0) - } - _ => 0.0, - }; - let q = (p.0 + nn.0 * o, p.1 + nn.1 * o); - if style == "rope" { - if k % 2 == 0 { - let (_, t, _) = b.at(s); - out.push(Prim::ellipse( - q, - unit * 0.45, - unit * 0.2, - t.1.atan2(t.0) + 0.7, - shade([vine[0], vine[1], vine[2]], 1.1), - shade([vine[0], vine[1], vine[2]], 0.65), - )); - } - } else if let Some(pp) = prev { - out.push(Prim::capsule(pp, q, thick, thick, vine, vine)); - } - prev = Some(q); - } - if style == "doubleLine" { - // A second rule inside each line and corner squares. - let inner = Border { - x0: b.x0 + unit * 0.6 + off, - y0: b.y0 + unit * 0.6 + off, - w: b.w - 2.0 * (unit * 0.6 + off), - h: b.h - 2.0 * (unit * 0.6 + off), - }; - for e in 0..4 { - let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]); - let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]); - out.push(Prim::capsule(p, q, thick * 0.6, thick * 0.6, vine, vine)); - } - } - } - if style == "doubleLine" { - for e in 0..4 { - let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]); - out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, fl, fl)); - } - } - } - "dots" | "scallops" | "snowflakes" | "stars" | "hearts" => { - for k in 0..count { - let s = k as f32 * step; - let (p, t, nn) = b.at(s); - let ta = t.1.atan2(t.0); - match style { - "dots" => { - let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 }; - out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75))); - } - "scallops" => { - let (c0, _, _) = b.at(s - step * 0.5); - let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0); - let n = 8; - for j in 0..n { - let a0 = std::f32::consts::PI * j as f32 / n as f32; - let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32; - let r = step * 0.5; - let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin()); - out.push(Prim::capsule(pt(a0), pt(a1), thick, thick, vine, vine)); - } - } - "snowflakes" => { - let r = unit * 0.6; - let c = [0.85, 0.92, 1.0, 1.0]; - for j in 0..6 { - let a = ta + j as f32 * std::f32::consts::FRAC_PI_3; - let e = (p.0 + a.cos() * r, p.1 + a.sin() * r); - out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c)); - let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6); - for sd in [-0.7f32, 0.7] { - let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3); - out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c)); - } - } - } - "stars" => star(&mut out, p, unit * 0.55, 5, if k % 2 == 0 { yellow } else { fl }, ta - std::f32::consts::FRAC_PI_2), - _ => { - // Heart: two lobes and a point towards the inside. - let r = unit * 0.3; - let (tx, ty) = t; - let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3); - let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3); - let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6); - out.push(Prim::capsule(l, tip, r, 0.6, fl, fl)); - out.push(Prim::capsule(rr, tip, r, 0.6, fl, fl)); - } - } - } - } - _ => {} } - // Fade: lower the opacity of ornaments that reach further inside. - let fade = spec.fade.clamp(0.0, 100.0) / 100.0; - if fade > 0.0 { + + /// Vine styles: a wavy stem with a leaf cluster or bloom at each step. + fn vine_styles(&self, out: &mut Vec, rng: &mut Rng) { + let (unit, step, fl, lf) = (self.unit, self.step, self.fl, self.lf); + let amp = unit * 0.5; + let wl = step * 2.0; + self.vine_path(out, amp, wl, self.vine, self.thick); + for k in 0..self.count { + let s = k as f32 * step + step * 0.5; + let (p, t, nn) = self.b.at(s); + let o = wave_off(s, amp, wl); + let q = (p.0 + nn.0 * o, p.1 + nn.1 * o); + let side = if k % 2 == 0 { 1.0 } else { -1.0 }; + let ta = t.1.atan2(t.0); + let la = ta + side * 0.9 + rng.sym() * 0.3; + let ll = unit * rng.range(0.9, 1.2); + match self.style { + "vineWithFlowers" => { + leaf(out, q, la, ll, lf); + if k % 2 == 0 { + let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side); + flower(out, c, unit * 0.65, 5, fl, FRAME_YELLOW, rng.f() * 6.3); + } + } + "vineWithLeaves" => { + leaf(out, q, la, ll, lf); + leaf(out, q, ta - side * 0.9, ll * 0.8, lf); + } + "ivy" => { + for j in 0..3 { + let a = la + (j as f32 - 1.0) * 0.7; + leaf(out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32)); + } + } + "roses" => { + leaf(out, q, la, ll, lf); + if k % 3 == 0 { + let r = unit * 0.7; + for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() { + let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32); + out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55))); + } + } + } + "daisies" => { + leaf(out, q, la, ll * 0.8, lf); + if k % 2 == 1 { + flower(out, q, unit * 0.6, 12, FRAME_WHITE, FRAME_YELLOW, rng.f()); + } + } + _ => { + leaf(out, q, la, ll, lf); + for _ in 0..3 { + let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4); + out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6))); + } + } + } + } + } + + /// Bamboo: jointed canes along each edge with a few leaves. + fn bamboo(&self, out: &mut Vec, rng: &mut Rng) { + let (b, unit, vine) = (&self.b, self.unit, self.vine); + for edge in 0..4 { + let (s0, l) = match edge { + 0 => (0.0, b.w), + 1 => (b.w, b.h), + 2 => (b.w + b.h, b.w), + _ => (2.0 * b.w + b.h, b.h), + }; + let (a, _, _) = b.at(s0 + 0.01); + let (z, _, _) = b.at(s0 + l - 0.01); + let joints = (l / (self.spacing * 2.0)).round().max(1.0) as usize; + for j in 0..joints { + let f0 = j as f32 / joints as f32; + let f1 = (j + 1) as f32 / joints as f32; + let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0); + let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1); + out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8))); + out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7))); + } + // A few leaves per edge. + for _ in 0..(joints / 2).max(1) { + let f = rng.f(); + let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f); + leaf(out, p, rng.f() * 6.3, unit * 1.4, self.lf); + } + } + } + + /// Continuous rule styles: waves, zigzag, double line and rope. + fn rule_styles(&self, out: &mut Vec) { + let (b, unit, thick, step, vine) = (&self.b, self.unit, self.thick, self.step, self.vine); + for li in 0..self.lines { + let off = (li as f32 - (self.lines - 1) as f32 / 2.0) * thick * 4.0; + let n = (self.total / 2.0).ceil().max(8.0) as usize; + let mut prev: Option<(f32, f32)> = None; + for k in 0..=n { + let s = k as f32 / n as f32 * self.total; + let (p, _, nn) = b.at(s); + let o = off + + match self.style { + "waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step), + "zigzag" => { + let ph = (s / step).fract(); + unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0) + } + _ => 0.0, + }; + let q = (p.0 + nn.0 * o, p.1 + nn.1 * o); + if self.style == "rope" { + if k % 2 == 0 { + let (_, t, _) = b.at(s); + out.push(Prim::ellipse( + q, + unit * 0.45, + unit * 0.2, + t.1.atan2(t.0) + 0.7, + shade([vine[0], vine[1], vine[2]], 1.1), + shade([vine[0], vine[1], vine[2]], 0.65), + )); + } + } else if let Some(pp) = prev { + out.push(Prim::capsule(pp, q, thick, thick, vine, vine)); + } + prev = Some(q); + } + if self.style == "doubleLine" { + self.inner_rule(out, off); + } + } + if self.style == "doubleLine" { + for e in 0..4 { + let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]); + out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, self.fl, self.fl)); + } + } + } + + /// A second rule inside each double line. + fn inner_rule(&self, out: &mut Vec, off: f32) { + let (b, unit) = (&self.b, self.unit); + let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) }; + for e in 0..4 { + let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]); + let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]); + out.push(Prim::capsule(p, q, self.thick * 0.6, self.thick * 0.6, self.vine, self.vine)); + } + } + + /// Evenly spaced motif styles: dots, scallops, snowflakes, stars and hearts. + fn motif_styles(&self, out: &mut Vec) { + let (unit, fl) = (self.unit, self.fl); + for k in 0..self.count { + let s = k as f32 * self.step; + let (p, t, nn) = self.b.at(s); + let ta = t.1.atan2(t.0); + match self.style { + "dots" => { + let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 }; + out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75))); + } + "scallops" => self.scallop(out, s, p, t, nn), + "snowflakes" => self.snowflake(out, p, ta), + "stars" => star(out, p, unit * 0.55, 5, if k % 2 == 0 { FRAME_YELLOW } else { fl }, ta - std::f32::consts::FRAC_PI_2), + _ => self.heart(out, p, t, nn), + } + } + } + + fn scallop(&self, out: &mut Vec, s: f32, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) { + let (c0, _, _) = self.b.at(s - self.step * 0.5); + let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0); + let n = 8; + for j in 0..n { + let a0 = std::f32::consts::PI * j as f32 / n as f32; + let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32; + let r = self.step * 0.5; + let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin()); + out.push(Prim::capsule(pt(a0), pt(a1), self.thick, self.thick, self.vine, self.vine)); + } + } + + fn snowflake(&self, out: &mut Vec, p: (f32, f32), ta: f32) { + let thick = self.thick; + let r = self.unit * 0.6; + let c = [0.85, 0.92, 1.0, 1.0]; + for j in 0..6 { + let a = ta + j as f32 * std::f32::consts::FRAC_PI_3; + let e = (p.0 + a.cos() * r, p.1 + a.sin() * r); + out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c)); + let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6); + for sd in [-0.7f32, 0.7] { + let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3); + out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c)); + } + } + } + + /// Heart: two lobes and a point towards the inside. + fn heart(&self, out: &mut Vec, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) { + let r = self.unit * 0.3; + let (tx, ty) = t; + let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3); + let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3); + let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6); + out.push(Prim::capsule(l, tip, r, 0.6, self.fl, self.fl)); + out.push(Prim::capsule(rr, tip, r, 0.6, self.fl, self.fl)); + } + + /// Fade: lower the opacity of ornaments that reach further inside. + fn apply_fade(&self, out: &mut [Prim], fade: f32, canvas: Rect) { let cx = canvas.x0 as f32 + canvas.width() as f32 / 2.0; let cy = canvas.y0 as f32 + canvas.height() as f32 / 2.0; - for p in &mut out { + for p in out { let c = match p.shape { Shape::Capsule { a, .. } => a, Shape::Ellipse { c, .. } => c, }; - let edge = ((c.0 - cx).abs() / (b.w / 2.0)).max((c.1 - cy).abs() / (b.h / 2.0)).clamp(0.0, 1.0); + let edge = ((c.0 - cx).abs() / (self.b.w / 2.0)).max((c.1 - cy).abs() / (self.b.h / 2.0)).clamp(0.0, 1.0); let k = 1.0 - fade * (1.0 - edge) * 8.0; p.c0[3] *= k.clamp(0.0, 1.0); p.c1[3] *= k.clamp(0.0, 1.0); } } +} + +/// Builds picture-frame primitives inside `canvas`. +pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec { + let mut out = Vec::new(); + let layout = FrameLayout::new(spec, canvas); + let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64); + match layout.style { + "vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => layout.vine_styles(&mut out, &mut rng), + "bamboo" => layout.bamboo(&mut out, &mut rng), + "waves" | "zigzag" | "doubleLine" | "rope" => layout.rule_styles(&mut out), + "dots" | "scallops" | "snowflakes" | "stars" | "hearts" => layout.motif_styles(&mut out), + _ => {} + } + let fade = spec.fade.clamp(0.0, 100.0) / 100.0; + if fade > 0.0 { + layout.apply_fade(&mut out, fade, canvas); + } out } diff --git a/crates/algo/src/shape_blur.rs b/crates/algo/src/shape_blur.rs index 26e9ad15bc..5b262afdcb 100644 --- a/crates/algo/src/shape_blur.rs +++ b/crates/algo/src/shape_blur.rs @@ -51,63 +51,14 @@ impl Prepared { if prefix_len > MAX_SAMPLES || output_len > MAX_SAMPLES { return None; } - let mut prefix = Vec::new(); - prefix.try_reserve_exact(prefix_len).ok()?; - prefix.resize(prefix_len, 0.0f64); - if input_row != 0 { - for (s, d) in src.data.chunks_exact(input_row).zip(prefix.chunks_exact_mut(stride)) { - let mut sum = [0.0f64; crate::fxutil::MAXC]; - for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) { - let alpha = if ctx.alpha { *px.last()? } else { 1.0 }; - for (c, ((v, sum), dst)) in px.iter().zip(sum.iter_mut()).zip(dst).enumerate() { - // Multiplication stays f32, exactly as premul_window. - let v = if ctx.alpha && c + 1 < n { *v * alpha } else { *v }; - *sum += f64::from(v); - *dst = *sum; - } - } - } - } + let prefix = Prefix::build(src, ctx, n, stride, prefix_len)?; let mut result = Vec::new(); result.try_reserve_exact(output_len).ok()?; let mut acc = Vec::new(); acc.try_reserve_exact(row_len).ok()?; acc.resize(row_len, 0.0f64); - let x = i64::from(out.x0) - i64::from(src.rect.x0); for y in out.y0..out.y1 { - acc.fill(0.0); - for &(dy, a, b) in &self.spans.rows { - let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy); - let Ok(yy) = usize::try_from(yy) else { - continue; - }; - if yy >= h { - continue; - } - let row = prefix.get(yy.checked_mul(stride)?..yy.checked_add(1)?.checked_mul(stride)?)?; - let lo = x + i64::from(a); - let hi = x + i64::from(b) + 1; - let end = out.width() as i64; - if lo >= 0 && hi + end <= w as i64 { - let start_lo = usize::try_from(lo).ok()?.checked_mul(n)?; - let start_hi = usize::try_from(hi).ok()?.checked_mul(n)?; - let low = row.get(start_lo..start_lo.checked_add(row_len)?)?; - let high = row.get(start_hi..start_hi.checked_add(row_len)?)?; - for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) { - *acc += hi - lo; - } - } else { - // Direct kernel callers may omit the halo or request pixels outside src. - // Retain the scalar kernel's zero extension and fixed normalization. - for (i, px) in acc.chunks_exact_mut(n).enumerate() { - let low = (lo + i as i64).clamp(0, w as i64) as usize * n; - let high = (hi + i as i64).clamp(0, w as i64) as usize * n; - for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) { - *acc += hi - lo; - } - } - } - } + self.accumulate_row(&prefix, src, out, y, &mut acc)?; for px in acc.chunks_exact(n) { let mut sample = [0.0f32; crate::fxutil::MAXC]; let sample = sample.get_mut(..n)?; @@ -120,4 +71,88 @@ impl Prepared { } Some(result) } + + /// Sum every kernel row's window for output row `y` into `acc`. + fn accumulate_row(&self, prefix: &Prefix, src: &Image, out: Rect, y: i32, acc: &mut [f64]) -> Option<()> { + let x = i64::from(out.x0) - i64::from(src.rect.x0); + acc.fill(0.0); + for &(dy, a, b) in &self.spans.rows { + let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy); + let Ok(yy) = usize::try_from(yy) else { + continue; + }; + if yy >= prefix.h { + continue; + } + let row = prefix.row(yy)?; + prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64)?; + } + Some(()) + } +} + +/// Per-row running channel sums (premultiplied when alpha is present), one +/// extra leading column per row so any window is a difference of two entries. +struct Prefix { + data: Vec, + stride: usize, + n: usize, + w: usize, + h: usize, +} + +impl Prefix { + fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize) -> Option { + let w = src.rect.width() as usize; + let h = src.rect.height() as usize; + let input_row = w.checked_mul(n)?; + let mut data = Vec::new(); + data.try_reserve_exact(len).ok()?; + data.resize(len, 0.0f64); + if input_row != 0 { + for (s, d) in src.data.chunks_exact(input_row).zip(data.chunks_exact_mut(stride)) { + let mut sum = [0.0f64; crate::fxutil::MAXC]; + for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) { + let alpha = if ctx.alpha { *px.last()? } else { 1.0 }; + for (c, ((v, sum), dst)) in px.iter().zip(sum.iter_mut()).zip(dst).enumerate() { + // Multiplication stays f32, exactly as premul_window. + let v = if ctx.alpha && c + 1 < n { *v * alpha } else { *v }; + *sum += f64::from(v); + *dst = *sum; + } + } + } + } + Some(Self { data, stride, n, w, h }) + } + + fn row(&self, y: usize) -> Option<&[f64]> { + self.data.get(y.checked_mul(self.stride)?..y.checked_add(1)?.checked_mul(self.stride)?) + } + + /// Add the `[lo, hi)` window difference of `row` for `acc.len() / n` output pixels. + fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64) -> Option<()> { + let (n, w) = (self.n, self.w); + let row_len = acc.len(); + if lo >= 0 && hi + end <= w as i64 { + let start_lo = usize::try_from(lo).ok()?.checked_mul(n)?; + let start_hi = usize::try_from(hi).ok()?.checked_mul(n)?; + let low = row.get(start_lo..start_lo.checked_add(row_len)?)?; + let high = row.get(start_hi..start_hi.checked_add(row_len)?)?; + for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) { + *acc += hi - lo; + } + } else { + // Direct kernel callers may omit the halo or request pixels outside src. + // Retain the scalar kernel's zero extension and fixed normalization. + for (i, px) in acc.chunks_exact_mut(n).enumerate() { + let low = (lo + i as i64).clamp(0, w as i64) as usize * n; + let high = (hi + i as i64).clamp(0, w as i64) as usize * n; + for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) { + *acc += hi - lo; + } + } + } + Some(()) + } } diff --git a/crates/algo/src/sharpen/exact.rs b/crates/algo/src/sharpen/exact.rs index 7d74bc538f..07ecba08bc 100644 --- a/crates/algo/src/sharpen/exact.rs +++ b/crates/algo/src/sharpen/exact.rs @@ -77,58 +77,94 @@ pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, t let row_len = ow.checked_mul(n)?; let th = oh.checked_add(2 * r)?; let ww = ow.checked_add(2 * r)?; - let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?); + let pass = Pass { src, out, ctx, kernel: &kernel, ctl, n, row_len }; let mut row = zeros(ww.checked_mul(n)?)?; let mut tmp = zeros(row_len.checked_mul(th)?)?; let mut result = zeros(row_len.checked_mul(oh)?)?; - for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() { - if ctl.cancelled() { - return None; - } - read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?; - premultiply(&mut row, n, ctx.alpha); - // Tap-major traversal exposes contiguous independent samples to LLVM. No - // reduction, tap merging, symmetry or fused multiply-add changes the sums. - for (i, weight) in kernel.iter().enumerate() { - let start = i.checked_mul(n)?; - let samples = row.get(start..start.checked_add(row_len)?)?; - for (d, s) in dst.iter_mut().zip(samples) { - *d += s * weight; + pass.horizontal(&mut row, &mut tmp)?; + pass.vertical(&tmp, &mut row, &mut result, amount / 100.0, threshold / 255.0)?; + Some(result) +} + +/// The shared inputs of the two separable blur passes. +struct Pass<'a> { + src: &'a Image, + out: Rect, + ctx: &'a Ctx, + kernel: &'a [f32], + ctl: &'a Interrupt<'a>, + n: usize, + row_len: usize, +} + +impl Pass<'_> { + /// Blur horizontally every source row the output (plus kernel halo) needs. + fn horizontal(&self, row: &mut [f32], tmp: &mut [f32]) -> Option<()> { + let (n, row_len) = (self.n, self.row_len); + let r = self.kernel.len() / 2; + let (x0, x1, y0) = (self.out.x0.checked_sub(r as i32)?, self.out.x1.checked_add(r as i32)?, self.out.y0.checked_sub(r as i32)?); + for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() { + if self.ctl.cancelled() { + return None; + } + read_row(self.src, x0, x1, y0.checked_add(y as i32)?, row)?; + premultiply(row, n, self.ctx.alpha); + // Tap-major traversal exposes contiguous independent samples to LLVM. No + // reduction, tap merging, symmetry or fused multiply-add changes the sums. + for (i, weight) in self.kernel.iter().enumerate() { + let start = i.checked_mul(n)?; + let samples = row.get(start..start.checked_add(row_len)?)?; + for (d, s) in dst.iter_mut().zip(samples) { + *d += s * weight; + } } } + Some(()) } - let (amount, threshold) = (amount / 100.0, threshold / 255.0); - let cc = n - usize::from(ctx.alpha); - for (y, dst) in result.chunks_exact_mut(row_len).enumerate() { - if ctl.cancelled() { - return None; - } - for (i, weight) in kernel.iter().enumerate() { - let start = y.checked_add(i)?.checked_mul(row_len)?; - let samples = tmp.get(start..start.checked_add(row_len)?)?; - for (d, s) in dst.iter_mut().zip(samples) { - *d += s * weight; + + /// Blur vertically and blend each output row against its original pixels. + /// `originals` is scratch space reused for the source row being compared. + fn vertical(&self, tmp: &[f32], originals: &mut [f32], result: &mut [f32], amount: f32, threshold: f32) -> Option<()> { + let row_len = self.row_len; + for (y, dst) in result.chunks_exact_mut(row_len).enumerate() { + if self.ctl.cancelled() { + return None; } + for (i, weight) in self.kernel.iter().enumerate() { + let start = y.checked_add(i)?.checked_mul(row_len)?; + let samples = tmp.get(start..start.checked_add(row_len)?)?; + for (d, s) in dst.iter_mut().zip(samples) { + *d += s * weight; + } + } + let originals = originals.get_mut(..row_len)?; + read_row(self.src, self.out.x0, self.out.x1, self.out.y0.checked_add(y as i32)?, originals)?; + self.sharpen_row(dst, originals, amount, threshold)?; } - let originals = row.get_mut(..row_len)?; - read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?; - for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) { - if ctx.alpha && *original.last()? <= 0.0 { + Some(()) + } + + /// Replace each blurred pixel by the thresholded unsharp-mask value. + fn sharpen_row(&self, blurred_row: &mut [f32], originals: &[f32], amount: f32, threshold: f32) -> Option<()> { + let (n, alpha) = (self.n, self.ctx.alpha); + let cc = n - usize::from(alpha); + for (blurred, original) in blurred_row.chunks_exact_mut(n).zip(originals.chunks_exact(n)) { + if alpha && *original.last()? <= 0.0 { blurred.copy_from_slice(original); continue; } - let a = if ctx.alpha { *blurred.last()? } else { 1.0 }; + let a = if alpha { *blurred.last()? } else { 1.0 }; for (b, o) in blurred.iter_mut().zip(original).take(cc) { // Keep the original unpremultiply cut-off and division, followed by // the threshold epsilon and inclusive comparison, at every depth. - let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b }; + let b_value = if alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b }; let d = o - b_value; *b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o }; } - if ctx.alpha { + if alpha { *blurred.last_mut()? = *original.last()?; } } + Some(()) } - Some(result) } diff --git a/crates/algo/src/sharpen/fft.rs b/crates/algo/src/sharpen/fft.rs index 6fdf18c8b0..57758b06d2 100644 --- a/crates/algo/src/sharpen/fft.rs +++ b/crates/algo/src/sharpen/fft.rs @@ -128,9 +128,9 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio let resident = samples.checked_mul(4)?; let mut planner = FftPlanner::::new(); let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?; - let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?; + let vertical_plan = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?; // A conservative allowance includes FFT plans/twiddles in addition to spectra. - let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?; + let plans = horizontal.size.checked_add(vertical_plan.size)?.checked_mul(256)?; let resident = resident.checked_add(plans)?; let workers = rayon::current_num_threads().max(1); let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?; @@ -139,7 +139,7 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio .checked_mul(n)? .checked_mul(4)? .checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)? - .checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?; + .checked_add(vertical_plan.size.checked_add(vertical_plan.scratch)?.checked_mul(16)?)?; let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?; let mut tmp = buffer(samples, 0.0f32)?; let finished = AtomicUsize::new(0); @@ -210,98 +210,29 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio } let mut out = surface.clone(); let count = w.div_ceil(STRIPE); + let vertical = VerticalPass { + surface, + selection, + tmp: &tmp, + plan: &vertical_plan, + ctl, + area, + n, + w, + h, + rows, + row_len, + r, + y0, + read_y0, + read_y1, + alpha, + amount, + kernel_len: kernel.len(), + }; for first in (0..count).step_by(group) { let end = first.checked_add(group)?.min(count); - let results: Option> = (first..end) - .into_par_iter() - .map(|index| { - if ctl.cancelled() { - return None; - } - let x = index.checked_mul(STRIPE)?; - let width = STRIPE.min(w.checked_sub(x)?); - let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?; - for (y, row) in tmp.chunks_exact(row_len).enumerate() { - if y % 64 == 0 && ctl.cancelled() { - return None; - } - for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() { - let start = (c * rows + y) * n; - columns.get_mut(start..start + n)?.copy_from_slice(px); - } - } - let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1); - let len = width.checked_mul(h)?.checked_mul(n)?; - let mut result = buffer(len, 0.0f32)?; - let mut original = buffer(len, 0.0f32)?; - surface.read_region_into(rect, &mut original); - let (mut data, mut scratch) = vertical.work()?; - for (c, samples) in columns.chunks_exact(rows * n).enumerate() { - if ctl.cancelled() { - return None; - } - let mut scales = [0.0f64; crate::fxutil::MAXC]; - for px in samples.chunks_exact(n) { - for (s, v) in scales.iter_mut().zip(px) { - *s = s.max(f64::from(v.abs())); - } - } - for channel in (0..n).step_by(2) { - if ctl.cancelled() { - return None; - } - data.fill(Number::default()); - for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() { - let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?); - let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?; - let px = samples.get(start..start + n)?; - let sample = |c: usize| -> f64 { - match (px.get(c), scales.get(c)) { - (Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s, - _ => 0.0, - } - }; - *d = Number::new(sample(channel), sample(channel + 1)); - } - vertical.convolve(&mut data, &mut scratch)?; - for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() { - let start = (y * width + c) * n; - *result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32; - if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) { - *v = (d.im * scales.get(channel + 1)?) as f32; - } - } - } - } - for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) { - if alpha && *o.last()? <= 0.0 { - px.copy_from_slice(o); - continue; - } - let a = if alpha { *px.last()? } else { 1.0 }; - // Near the original unpremultiply cutoff, cancellation/FFT noise - // or f32 sum order can expose colour. Decline the whole fast path. - let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON; - if alpha && a <= 1e-7 + uncertainty { - return None; - } - for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) { - let b = if alpha { *v / a } else { *v }; - *v = o + amount / 100.0 * (o - b); - if !v.is_finite() { - return None; - } - } - if alpha { - *px.last_mut()? = *o.last()?; - } - } - if let Some(sel) = selection { - crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha); - } - Some((rect, result)) - }) - .collect(); + let results: Option> = (first..end).into_par_iter().map(|index| vertical.stripe(index)).collect(); for (rect, data) in results? { if ctl.cancelled() { return None; @@ -321,5 +252,145 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio Some(out) } +/// Everything the vertical pass needs to turn one stripe of horizontally +/// convolved columns into finished, sharpened output pixels. +struct VerticalPass<'a> { + surface: &'a Surface, + selection: Option<&'a Surface>, + tmp: &'a [f32], + plan: &'a Plan, + ctl: &'a Interrupt<'a>, + area: Rect, + n: usize, + w: usize, + h: usize, + rows: usize, + row_len: usize, + r: usize, + y0: i32, + read_y0: i32, + read_y1: i32, + alpha: bool, + amount: f32, + kernel_len: usize, +} + +/// Channel `c` of `px` scaled into [-1, 1], or zero when the column is all zero. +fn normalized(px: &[f32], scales: &[f64], c: usize) -> f64 { + match (px.get(c), scales.get(c)) { + (Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s, + _ => 0.0, + } +} + +impl VerticalPass<'_> { + /// Process the `index`th stripe of columns, returning its rect and pixels. + fn stripe(&self, index: usize) -> Option<(Rect, Vec)> { + let (ctl, n, h) = (self.ctl, self.n, self.h); + if ctl.cancelled() { + return None; + } + let x = index.checked_mul(STRIPE)?; + let width = STRIPE.min(self.w.checked_sub(x)?); + let columns = self.gather_columns(x, width)?; + let rect = Rect::new(self.area.x0.checked_add(x as i32)?, self.area.y0, self.area.x0.checked_add((x + width) as i32)?, self.area.y1); + let len = width.checked_mul(h)?.checked_mul(n)?; + let mut result = buffer(len, 0.0f32)?; + let mut original = buffer(len, 0.0f32)?; + self.surface.read_region_into(rect, &mut original); + let (mut data, mut scratch) = self.plan.work()?; + for (c, samples) in columns.chunks_exact(self.rows * n).enumerate() { + if ctl.cancelled() { + return None; + } + self.convolve_column(samples, c, width, (&mut data, &mut scratch), &mut result)?; + } + self.sharpen(&mut result, &original)?; + if let Some(sel) = self.selection { + crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, self.alpha); + } + Some((rect, result)) + } + + /// Transpose the stripe at `x` of the horizontal results into column-major order. + fn gather_columns(&self, x: usize, width: usize) -> Option> { + let (n, rows) = (self.n, self.rows); + let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?; + for (y, row) in self.tmp.chunks_exact(self.row_len).enumerate() { + if y % 64 == 0 && self.ctl.cancelled() { + return None; + } + for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() { + let start = (c * rows + y) * n; + columns.get_mut(start..start + n)?.copy_from_slice(px); + } + } + Some(columns) + } + + /// Convolve one gathered column, two channels per complex FFT, into `result`. + fn convolve_column(&self, samples: &[f32], c: usize, width: usize, work: (&mut [Number], &mut [Number]), result: &mut [f32]) -> Option<()> { + let (n, h, r) = (self.n, self.h, self.r); + let (data, scratch) = work; + let mut scales = [0.0f64; crate::fxutil::MAXC]; + for px in samples.chunks_exact(n) { + for (s, v) in scales.iter_mut().zip(px) { + *s = s.max(f64::from(v.abs())); + } + } + for channel in (0..n).step_by(2) { + if self.ctl.cancelled() { + return None; + } + data.fill(Number::default()); + for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() { + let sy = self.y0.checked_add(i as i32)?.clamp(self.read_y0, self.read_y1.checked_sub(1)?); + let start = usize::try_from(sy - self.read_y0).ok()?.checked_mul(n)?; + let px = samples.get(start..start + n)?; + *d = Number::new(normalized(px, &scales, channel), normalized(px, &scales, channel + 1)); + } + self.plan.convolve(data, scratch)?; + for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() { + let start = (y * width + c) * n; + *result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32; + if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) { + *v = (d.im * scales.get(channel + 1)?) as f32; + } + } + } + Some(()) + } + + /// Turn the blurred stripe into the unsharp-masked result against `original`. + /// Declines (`None`) wherever the fast path cannot match the exact kernel. + fn sharpen(&self, result: &mut [f32], original: &[f32]) -> Option<()> { + let (n, alpha) = (self.n, self.alpha); + for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) { + if alpha && *o.last()? <= 0.0 { + px.copy_from_slice(o); + continue; + } + let a = if alpha { *px.last()? } else { 1.0 }; + // Near the original unpremultiply cutoff, cancellation/FFT noise + // or f32 sum order can expose colour. Decline the whole fast path. + let uncertainty = 4.0 * self.kernel_len as f32 * f32::EPSILON; + if alpha && a <= 1e-7 + uncertainty { + return None; + } + for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) { + let b = if alpha { *v / a } else { *v }; + *v = o + self.amount / 100.0 * (o - b); + if !v.is_finite() { + return None; + } + } + if alpha { + *px.last_mut()? = *o.last()?; + } + } + Some(()) + } +} + #[cfg(test)] mod tests; diff --git a/crates/algo/src/stylize2/extrude.rs b/crates/algo/src/stylize2/extrude.rs index 78a3df273b..ae70351c09 100644 --- a/crates/algo/src/stylize2/extrude.rs +++ b/crates/algo/src/stylize2/extrude.rs @@ -20,7 +20,6 @@ pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> O if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) { return None; } - let n = src.ch; let w = out.width() as usize; let count = w.checked_mul(out.height() as usize)?; if count > MAX_PIXELS { @@ -44,111 +43,190 @@ pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> O if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS { return None; } - let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0); - let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0); - let half = cs as f32 / 2.0; - let cc = ncol(ctx, n); - let sub = subtractive(ctx); + let pass = BlockPass { + src, + out, + ctx, + spec, + b, + area, + cs, + k, + reach, + centre: ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0), + half_diag: ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0), + half: cs as f32 / 2.0, + }; // Ascending gy, then gx, is the tie order of every pixel's old candidate search. for cy in cy0..=cy1 { for cx in cx0..=cx1 { - let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs); - let clipped = r.intersect(&b); - if spec.mask_incomplete && clipped != r { - continue; - } - let mut col = [0.0f32; MAXC]; - let mut cnt = 0.0; - let step = (cs / 8).max(1) as usize; - for yy in (clipped.y0..clipped.y1).step_by(step) { - for xx in (clipped.x0..clipped.x1).step_by(step) { - let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 }; - for (c, v) in col.iter_mut().enumerate().take(n) { - let s = src.get(xx, yy, c); - *v += if ctx.alpha && c < n - 1 { s * a } else { s }; - } - cnt += 1.0; - } - } - if cnt > 0.0 { - for v in col.iter_mut().take(n) { - *v /= cnt; - } - } - unpremul_px(&mut col[..n], ctx.alpha); - let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) }; - if !h.is_finite() { - return None; - } - let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half); - let off = reach * h / half_diag; - let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off); - // Two extra pixels conservatively cover rounding in the unchanged interval test. - let sweep = Rect::new( - (mx.min(mx + ox) - half).floor() as i32 - 2, - (my.min(my + oy) - half).floor() as i32 - 2, - (mx.max(mx + ox) + half).ceil() as i32 + 2, - (my.max(my + oy) + half).ceil() as i32 + 2, - ) - .intersect(&area); - for y in sweep.y0..sweep.y1 { - let gy = (y - b.y0).div_euclid(cs); - if (cy - gy).abs() > k { - continue; - } - let fy = y as f32 + 0.5; - let Some(iy) = interval(fy, my, oy, half) else { continue }; - for x in sweep.x0..sweep.x1 { - if (cx - (x - b.x0).div_euclid(cs)).abs() > k { - continue; - } - let i = (y - out.y0) as usize * w + (x - out.x0) as usize; - let height = heights.get_mut(i)?; - if *height >= h { - continue; - } - let fx = x as f32 + 0.5; - let Some(ix) = interval(fx, mx, ox, half) else { continue }; - let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1)); - if lo > hi { - continue; - } - *height = h; - let face = if hi >= 1.0 { - 0 - } else if ix.1 < iy.1 { - if ox > 0.0 { 1 } else { 2 } - } else if oy > 0.0 { - 3 - } else { - 4 - }; - let px = res.get_mut(i * n..(i + 1) * n)?; - if face == 0 && !spec.solid_front { - let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32); - read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px); - } else { - let shade = match face { - 0 => 1.0, - 1 => 0.85, - 2 => 0.55, - 3 => 0.95, - _ => 0.45, - }; - for (v, c) in px.iter_mut().zip(&col).take(cc) { - *v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0); - } - if ctx.alpha { - *px.last_mut()? = col.get(n - 1).copied()?; - } - } - } - } + pass.paint_cell(cx, cy, &mut heights, &mut res)?; } } Some(res) } +/// The constants shared by every swept block of one `blocks` call. +struct BlockPass<'a> { + src: &'a Image, + out: Rect, + ctx: &'a Ctx, + spec: &'a ExtrudeSpec, + b: Rect, + area: Rect, + cs: i32, + k: i32, + reach: f32, + centre: (f32, f32), + half_diag: f32, + half: f32, +} + +/// One cell's flat colour and where its extruded block sweeps to. +struct Block { + r: Rect, + cx: i32, + m: (f32, f32), + o: (f32, f32), + off: f32, + h: f32, + col: [f32; MAXC], +} + +impl BlockPass<'_> { + /// Average the (premultiplied) source over `clipped`, sampling every `step` pixels. + fn cell_colour(&self, clipped: Rect) -> [f32; MAXC] { + let (src, ctx, n) = (self.src, self.ctx, self.src.ch); + let mut col = [0.0f32; MAXC]; + let mut cnt = 0.0; + let step = (self.cs / 8).max(1) as usize; + for yy in (clipped.y0..clipped.y1).step_by(step) { + for xx in (clipped.x0..clipped.x1).step_by(step) { + let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 }; + for (c, v) in col.iter_mut().enumerate().take(n) { + let s = src.get(xx, yy, c); + *v += if ctx.alpha && c < n - 1 { s * a } else { s }; + } + cnt += 1.0; + } + } + if cnt > 0.0 { + for v in col.iter_mut().take(n) { + *v /= cnt; + } + } + unpremul_px(&mut col[..n], ctx.alpha); + col + } + + /// Rasterize the block of grid cell (`cx`, `cy`) over the pixels it can hit. + fn paint_cell(&self, cx: i32, cy: i32, heights: &mut [f32], res: &mut [f32]) -> Option<()> { + let (b, cs, half) = (self.b, self.cs, self.half); + let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs); + let clipped = r.intersect(&b); + if self.spec.mask_incomplete && clipped != r { + return Some(()); + } + let col = self.cell_colour(clipped); + let n = self.src.ch; + let h = if self.spec.level_based { luma(self.ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, self.spec.seed) }; + if !h.is_finite() { + return None; + } + let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half); + let off = self.reach * h / self.half_diag; + let o = ((mx - self.centre.0) * off, (my - self.centre.1) * off); + // Two extra pixels conservatively cover rounding in the unchanged interval test. + let sweep = Rect::new( + (mx.min(mx + o.0) - half).floor() as i32 - 2, + (my.min(my + o.1) - half).floor() as i32 - 2, + (mx.max(mx + o.0) + half).ceil() as i32 + 2, + (my.max(my + o.1) + half).ceil() as i32 + 2, + ) + .intersect(&self.area); + let block = Block { r, cx, m: (mx, my), o, off, h, col }; + for y in sweep.y0..sweep.y1 { + let gy = (y - b.y0).div_euclid(cs); + if (cy - gy).abs() > self.k { + continue; + } + for x in sweep.x0..sweep.x1 { + self.paint_pixel(&block, x, y, heights, res)?; + } + } + Some(()) + } + + /// Paint pixel (`x`, `y`) if `block` covers it and is taller than what is there. + fn paint_pixel(&self, block: &Block, x: i32, y: i32, heights: &mut [f32], res: &mut [f32]) -> Option<()> { + let (b, cs, half, n) = (self.b, self.cs, self.half, self.src.ch); + let (mx, my) = block.m; + let (ox, oy) = block.o; + if (block.cx - (x - b.x0).div_euclid(cs)).abs() > self.k { + return Some(()); + } + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let i = (y - self.out.y0) as usize * self.out.width() as usize + (x - self.out.x0) as usize; + let height = heights.get_mut(i)?; + if *height >= block.h { + return Some(()); + } + let Some(iy) = interval(fy, my, oy, half) else { return Some(()) }; + let Some(ix) = interval(fx, mx, ox, half) else { return Some(()) }; + let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1)); + if lo > hi { + return Some(()); + } + *height = block.h; + let face = block_face(hi, ix.1 < iy.1, ox, oy); + let px = res.get_mut(i * n..(i + 1) * n)?; + self.write_face(block, face, (fx, fy), px) + } + + /// Write `face` of `block` into pixel `px` centred at `(fx, fy)`: the front + /// face shows the shifted source, every face can instead be flat-shaded. + fn write_face(&self, block: &Block, face: u8, (fx, fy): (f32, f32), px: &mut [f32]) -> Option<()> { + let n = self.src.ch; + if face == 0 && !self.spec.solid_front { + let (sx, sy) = ((fx - (block.m.0 - self.centre.0) * block.off).floor() as i32, (fy - (block.m.1 - self.centre.1) * block.off).floor() as i32); + read_px(self.src, sx.clamp(block.r.x0, block.r.x1 - 1), sy.clamp(block.r.y0, block.r.y1 - 1), px); + } else { + let shade = face_shade(face); + let sub = subtractive(self.ctx); + for (v, c) in px.iter_mut().zip(&block.col).take(ncol(self.ctx, n)) { + *v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0); + } + if self.ctx.alpha { + *px.last_mut()? = block.col.get(n - 1).copied()?; + } + } + Some(()) + } +} + +/// Which face of the swept block a pixel shows: 0 front, 1/2 right/left, 3/4 bottom/top. +fn block_face(hi: f32, x_exits_first: bool, ox: f32, oy: f32) -> u8 { + if hi >= 1.0 { + 0 + } else if x_exits_first { + if ox > 0.0 { 1 } else { 2 } + } else if oy > 0.0 { + 3 + } else { + 4 + } +} + +fn face_shade(face: u8) -> f32 { + match face { + 0 => 1.0, + 1 => 0.85, + 2 => 0.55, + 3 => 0.95, + _ => 0.45, + } +} + fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> { if o.abs() < 1e-6 { return ((p - m).abs() <= half).then_some((0.0, 1.0)); diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 13ab9c527b..ba073af2ff 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] photocraft-doc = { workspace = true } +photocraft-raster = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } photocraft-io = { workspace = true } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index fc4526eedb..0b8ff103aa 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -33,9 +33,23 @@ pub fn open(path: &Path) -> Result { let document = photocraft_format::load_path(path)?; return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } - let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - open_bytes(&name, &bytes) + // A PSD/PSB is parsed from the open file (channel data read as it is decoded), so a CLI + // convert or info of a 2 GB file does not hold the file in memory next to the document. + #[cfg(any(unix, windows))] + { + let r = + photocraft_io::import_file(&name, path, &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE).map_err(|e| match e { + photocraft_io::IoError::Read(msg) => AutomationError::Io(msg), + e => e.into(), + })?; + Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) + } + #[cfg(not(any(unix, windows)))] + { + let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + open_bytes(&name, &bytes) + } } /// Previews for a `.pcraft` bundle. diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index b7e22b3ffa..5136b0363b 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -377,6 +377,44 @@ fn doc_place_reads_through_the_read_root_and_never_outside_it() { std::fs::remove_dir_all(base).unwrap(); } +/// A save or render never turns a file PhotoCraft doesn't write (source, settings, notes) +/// into image bytes, whatever `format` says; images and new files are written as before (#9439). +#[test] +fn workspace_writes_refuse_to_replace_a_file_that_is_not_an_image() { + let root = std::env::temp_dir().join(format!("pc-no-replace-foreign-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("src")).unwrap(); + let foreign = [("src/main.rs", "fn main() {}\n"), ("Cargo.toml", "[package]\n"), (".env", "TOKEN=x\n"), ("Makefile", "all:\n")]; + for (name, text) in foreign { + std::fs::write(root.join(name), text).unwrap(); + } + std::fs::write(root.join("old.png"), b"not yet a png").unwrap(); + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + for (name, text) in foreign { + let saved = h.handle("doc.save", json!({"path": name, "format": "png"})).unwrap_err().to_string(); + assert!(saved.contains("already exists"), "doc.save over {name}: {saved}"); + let rendered = h.handle("doc.render", json!({"path": name})).unwrap_err().to_string(); + assert!(rendered.contains("already exists"), "doc.render over {name}: {rendered}"); + assert_eq!(std::fs::read_to_string(root.join(name)).unwrap(), text, "{name} is untouched"); + } + // An existing image is replaced, and a new file of any name is created. + h.handle("doc.save", json!({"path": "old.png"})).unwrap(); + assert!(std::fs::read(root.join("old.png")).unwrap().starts_with(b"\x89PNG")); + h.handle("doc.save", json!({"path": "fresh.txt", "format": "png"})).unwrap(); + assert!(std::fs::read(root.join("fresh.txt")).unwrap().starts_with(b"\x89PNG")); + h.handle("doc.render", json!({"path": "old.png"})).unwrap(); + std::fs::write(root.join("OLD.TIF"), b"x").unwrap(); + h.handle("doc.save", json!({"path": "OLD.TIF"})).unwrap(); + // An image saved under a name without an image extension is recognised by its bytes. + h.handle("doc.save", json!({"path": "out", "format": "png"})).unwrap(); + h.handle("doc.save", json!({"path": "out", "format": "png"})).unwrap(); + h.handle("doc.render", json!({"path": "fresh.txt"})).unwrap(); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() { let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id())); diff --git a/crates/automation/src/server/conventions.rs b/crates/automation/src/server/conventions.rs index 6e2740041d..b48c41db87 100644 --- a/crates/automation/src/server/conventions.rs +++ b/crates/automation/src/server/conventions.rs @@ -1,7 +1,10 @@ //! Catalog metadata, strict tool envelopes and live resources. -use super::*; +use super::{Arc, Backend, BatchParams, DocIndex, ListParams, McpError, Parameters, PhotocraftMcp, RunParams, ServerHandler, Value, json}; use rmcp::RoleServer; -use rmcp::model::*; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, Implementation, ListResourcesResult, ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, + ReadResourceResponse, ReadResourceResult, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, +}; use rmcp::service::RequestContext; const DOCUMENT: &str = "photocraft://document"; diff --git a/crates/automation/src/server/progress.rs b/crates/automation/src/server/progress.rs index 4182743083..1844203f8e 100644 --- a/crates/automation/src/server/progress.rs +++ b/crates/automation/src/server/progress.rs @@ -1,5 +1,5 @@ //! Render Video runs on the existing blocking worker; rmcp remains available for ping/cancel. -use super::*; +use super::{CallToolResult, PhotocraftMcp, Value, fail, no_backend, ok_json}; use rmcp::RoleServer; use rmcp::model::ProgressNotificationParam; use rmcp::service::RequestContext; diff --git a/crates/automation/src/server/transport.rs b/crates/automation/src/server/transport.rs index 82e96b13c3..87a98e0305 100644 --- a/crates/automation/src/server/transport.rs +++ b/crates/automation/src/server/transport.rs @@ -1,5 +1,5 @@ //! Recover malformed JSON lines before passing valid messages to rmcp. -use super::*; +use super::{AutomationError, PhotocraftMcp, ServiceExt, Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::security::MAX_REQUEST_BYTES; diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 13cbbb2169..4dd1a5df05 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -88,6 +88,22 @@ impl AuthorizedWorkspace { let relative = request_path(path, self.write.as_ref())?; let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + // The bytes are an image or document whatever the name says (`format` picks them), so + // only a file of a kind PhotoCraft writes is replaced: never source, settings or notes + // that share the root (`src/main.rs`, `.env`; #9439). A new file may have any name, and + // an image saved under one (`out` with `format: png`) can be saved over again. + let exists = root.dir.symlink_metadata(&relative).is_ok(); + // Only a regular file is ever opened below (its head here, its attributes on Unix): a + // FIFO would block that open until a writer appears, and with it every other request. + // Links are followed for this check; a dangling one falls through to the name rule. + if exists && root.dir.metadata(&relative).is_ok_and(|m| !m.is_file()) { + return Err(AutomationError::BadRequest(format!("automation write refused: `{path}` already exists and is not a regular file"))); + } + if exists && !written_kind(&leaf) && !holds_image(&root.dir, &relative) { + return Err(AutomationError::BadRequest(format!( + "automation write refused: `{path}` already exists and is not an image or document file PhotoCraft writes; save to a new name" + ))); + } let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default(); let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); @@ -124,6 +140,23 @@ impl AuthorizedWorkspace { } } +/// Whether `name`'s extension is one of the formats PhotoCraft saves or exports. +fn written_kind(name: &str) -> bool { + let Some((_, ext)) = name.rsplit_once('.') else { return false }; + let ext = ext.to_ascii_lowercase(); + ["psd", "psb", "ora", "pdf", photocraft_format::EXTENSION].contains(&ext.as_str()) || photocraft_codecs::from_extension(&ext).is_some() +} + +/// Whether the file at `relative` starts like an image PhotoCraft reads (PSD/PSB or a codec's +/// magic). Containers that also hold other things (zip, PDF) are judged by name only. +fn holds_image(dir: &Dir, relative: &Path) -> bool { + let mut head = [0u8; 64]; + let Ok(mut file) = dir.open(relative) else { return false }; + let Ok(n) = file.read(&mut head) else { return false }; + let head = &head[..n]; + head.starts_with(b"8BPS") || photocraft_codecs::detect(head).is_some() +} + fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result, AutomationError> { // Do not reserve from a potentially stale or attacker-controlled metadata length. Read in // chunks, and reject growth beyond the length observed on the opened file before appending. @@ -221,11 +254,14 @@ fn relative_path(raw: &str) -> Result { Ok(PathBuf::from(raw)) } +/// At least every name cap-std refuses to open on Windows (`COM0`–`COM9`, `COM¹`–`COM³`, the same +/// for `LPT`, spaces before the extension ignored), so nothing written is unreadable (#9424). fn is_windows_device_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).to_ascii_uppercase(); + let stem = component.split('.').next().unwrap_or(component).trim_end().to_ascii_uppercase(); + let port = |n: &str| matches!(n, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"); matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") - || stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) - || stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) + || stem.strip_prefix("COM").is_some_and(port) + || stem.strip_prefix("LPT").is_some_and(port) } fn path_error(path: &str, reason: &str) -> AutomationError { diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 2a94de15f2..ad5138ef12 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -122,6 +122,46 @@ fn write_replaces_atomically_and_leaves_no_temp_file() { assert!(temp_files(&inside).is_empty()); } +/// A directory at the target is refused before anything is opened, even under an image name. +#[test] +fn write_refuses_a_directory_target_before_opening_it() { + let (inside, _, workspace) = roots("dir-target"); + for name in ["adir", "adir.png"] { + std::fs::create_dir_all(inside.join(name)).unwrap(); + let error = workspace.write(name, b"x").unwrap_err().to_string(); + assert!(error.contains("is not a regular file"), "{name}: {error}"); + assert!(inside.join(name).is_dir()); + } + assert!(temp_files(&inside).is_empty()); +} + +/// A FIFO at the target is refused at once: opening it to sniff its head or copy its +/// attributes would block until a writer appears, stalling every other request. +#[cfg(unix)] +#[test] +fn write_refuses_a_fifo_target_without_blocking() { + let (inside, _, workspace) = roots("fifo-target"); + for name in ["pipe", "out.png"] { + let made = std::process::Command::new("mkfifo").arg(inside.join(name)).status(); + if !made.is_ok_and(|s| s.success()) { + return; // no mkfifo on this system: nothing to assert + } + } + std::os::unix::fs::symlink("pipe", inside.join("link")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for name in ["pipe", "out.png", "link"] { + let _ = tx.send((name, workspace.write(name, b"x").map_err(|e| e.to_string()))); + } + }); + for _ in 0..3 { + let (name, result) = rx.recv_timeout(std::time::Duration::from_secs(5)).expect("write blocked on a FIFO"); + let error = result.unwrap_err(); + assert!(error.contains("is not a regular file"), "{name}: {error}"); + } + assert!(temp_files(&inside).is_empty()); +} + /// #7095: replacing a file through the automation path keeps the old file's extended /// attributes, the same as a save through `atomic_write` does. #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] @@ -177,6 +217,32 @@ fn rejects_absolute_traversal_mixed_prefix_and_malformed_paths_without_panicking } } +#[test] +fn every_device_name_cap_std_refuses_to_open_is_refused_before_any_write() { + // cap-std refuses these when it opens a file on Windows, so a file written under one of + // them could not be read back (#9424). + let (inside, _, workspace) = roots("devices"); + std::fs::create_dir(inside.join("sub")).unwrap(); + for path in ["COM0.txt", "LPT0", "COM¹.png", "com².png", "COM³", "LPT¹.txt", "LPT².txt", "lpt³", "NUL .png", "Aux .txt", "sub/CON .psd", "COM¹/frame.png"] + { + let error = workspace.write(path, b"x").unwrap_err().to_string(); + assert!(error.contains("reserved device names"), "write {path:?}: {error}"); + let error = workspace.read(path).unwrap_err().to_string(); + assert!(error.contains("reserved device names"), "read {path:?}: {error}"); + } + for dir in ["COM¹", "LPT0", "sub/PRN .x"] { + let error = workspace.export_sequence(dir).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(error.contains("reserved device names"), "export {dir:?}: {error}"); + } + assert_eq!(std::fs::read_dir(&inside).unwrap().count(), 1, "only `sub` may exist"); + assert_eq!(std::fs::read_dir(inside.join("sub")).unwrap().count(), 0); + // Look-alikes are ordinary names. + for path in ["COM10.txt", "COM¹0.txt", "LPT00", "console.txt", "NULL.txt", "nul_.txt", " NUL.txt"] { + workspace.write(path, b"x").unwrap_or_else(|e| panic!("write {path:?}: {e}")); + assert_eq!(workspace.read(path).unwrap(), b"x", "{path:?}"); + } +} + #[test] fn read_and_write_authority_are_separate() { let (inside, _, _) = roots("separate"); @@ -272,6 +338,10 @@ fn filesystem_commands_fail_closed() { for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers"] { assert!(authorize_engine_command(id, &serde_json::json!({"path": "/outside/source.psb"})).is_err(), "{id}"); } +} + +#[test] +fn filesystem_command_paths_and_profiles_fail_closed() { // The UI-level examples in docs/control-protocol.md. for id in ["view.zoomIn", "window.theme.pro", "edit.search"] { assert!(authorize_desktop_engine_command(id, &serde_json::json!({})).is_ok(), "{id}"); diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 6a85f53908..3feeb3a97d 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -87,16 +87,50 @@ impl LutFile { } } +/// A LUT file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Cube, + ThreeDl, + Look, +} + +impl Format { + /// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed + /// (XML is `.look`, anything else `.cube`). + pub fn detect(file_name: &str, bytes: &[u8]) -> Self { + let lower = file_name.to_ascii_lowercase(); + if lower.ends_with(".3dl") { + Format::ThreeDl + } else if lower.ends_with(".look") || decode_text(bytes).trim_start().starts_with('<') { + Format::Look + } else { + Format::Cube + } + } + + /// The file extension, without the dot. + pub fn extension(self) -> &'static str { + match self { + Format::Cube => "cube", + Format::ThreeDl => "3dl", + Format::Look => "look", + } + } +} + /// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing). pub fn parse(file_name: &str, bytes: &[u8]) -> Result { - let lower = file_name.to_ascii_lowercase(); + parse_as(Format::detect(file_name, bytes), bytes) +} + +/// Parses a LUT in a known format. +pub fn parse_as(format: Format, bytes: &[u8]) -> Result { let text = decode_text(bytes); - if lower.ends_with(".3dl") { - parse_3dl(&text) - } else if lower.ends_with(".look") || text.trim_start().starts_with('<') { - parse_look(&text) - } else { - parse_cube(&text) + match format { + Format::Cube => parse_cube(&text), + Format::ThreeDl => parse_3dl(&text), + Format::Look => parse_look(&text), } } @@ -518,6 +552,9 @@ DOMAIN_MAX 0.5 1 1 let be: Vec = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect(); assert_eq!(parse("a.look", &le).unwrap().data, id.data); assert_eq!(parse("a.look", &be).unwrap().data, id.data); + // Sniffed as XML through the byte-order mark too, so the recorded format is the one parsed. + assert_eq!(Format::detect("a.lut", &le), Format::Look); + assert_eq!(parse_as(Format::Look, &be).unwrap().data, id.data); } #[test] @@ -570,6 +607,17 @@ DOMAIN_MAX 0.5 1 1 assert!(parse_look(&text).is_err()); } + #[test] + fn format_detection_matches_parse() { + assert_eq!(Format::detect("a.3DL", b""), Format::ThreeDl); + assert_eq!(Format::detect("a.Look", b""), Format::Look); + assert_eq!(Format::detect("a.cube", b" "), Format::Look, "XML is sniffed as .look"); + assert_eq!(Format::detect("a.txt", b"LUT_3D_SIZE 2"), Format::Cube); + assert_eq!(Format::ThreeDl.extension(), "3dl"); + let text = write_cube(&LutFile::identity(2)); + assert_eq!(parse_as(Format::Cube, text.as_bytes()), parse("a.cube", text.as_bytes())); + } + #[test] fn malformed_rejected() { assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err()); diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 4fe9def532..0652112bf1 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -187,7 +187,7 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are // in, a one-byte read checks that the stream ends there. let malformed = || err(format!("{what}: zlib data is malformed")); - let mut zlib = flate2::read::ZlibDecoder::new(packed); + let mut zlib = flate2::bufread::ZlibDecoder::new(packed); let mut out = Vec::new(); let mut filled = 0; while filled < expected { @@ -756,6 +756,7 @@ mod tests { let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); z.write_all(&p).expect("write to a Vec"); let zips = z.finish().expect("finish a Vec"); + assert!(decompress(Compression::ZIP1, &zips[..zips.len() - 1], n, "the table").is_err()); // RLE: the first byte as a literal, then runs of up to 128 copies of the next. let mut rle = vec![0xFF, p[0]]; for run in p[1..].chunks(128) { diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 2b32a2b7c4..e179b13044 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -39,16 +39,30 @@ pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result limits.max_alloc { return Err(CodecError::LimitExceeded(format!("{total} bytes across {} parts exceed max_alloc {}", meta.headers.len(), limits.max_alloc))); } + // The `exr` crate allocates a flat part's samples before reading its chunks, so the file + // must be able to hold them (#8597). The other compressions stay far below 2^16:1 even + // against this f32 budget. DWA's run-length channels are zlib-packed on top and reach about + // 132000:1, so DWA parts get 2^18:1. Deep parts size their samples from the file's own counts. + if !meta.requirements.has_deep_data { + Limits::check_plausible(total.saturating_sub(dwa).saturating_add(dwa / 4), bytes.len(), 1 << 16)?; + } Ok(meta) } diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 3e533ccd08..f2c4d4b9ee 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -150,8 +150,13 @@ fn data_end(b: &[u8]) -> (DataEnd, u64) { loop { let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) }; if byte != 0xFF { - // Not where a marker should be: too odd to judge. - return (DataEnd::Complete, scanned); + // Not where a marker should be: skip to the next one, as a lenient decoder does, so + // segments after the stray bytes (padding among them) are still recognised. Stray + // bytes are not scan data, so they never count (junk would buy budget as padding + // did); with no marker left, the image data ends here. + let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else { return cut(scan_data, scanned) }; + i += p; + continue; } let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) }; match code { @@ -232,9 +237,10 @@ fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option) Ok((img, d.damaged)) } -/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few -/// bits per 8×8 block, and truncated photographs are far above this. -const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048; +/// Most pixels one byte of a JPEG may account for. Every 8×8 block of a frame needs at least a +/// one-bit DC code (512 pixels a byte for a flat image under ideal Huffman tables), and real files, +/// truncated or not, are far above this. +const MAX_PIXELS_PER_BYTE: u64 = 2048; pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { let (end, scan_bytes) = data_end(bytes); @@ -252,7 +258,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } if let Some((w, h, _)) = meta.frame && end == DataEnd::Truncated - && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes + // Only truncated scans: arithmetic coding (SOF9–SOF11) can spend well under a bit on + // a flat block, so a complete scan may be shorter than this bound. + && u64::from(w) * u64::from(h) / MAX_PIXELS_PER_BYTE > scan_bytes { return Err(err("the truncated scan is too short for the frame it declares")); } @@ -277,12 +285,14 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result _ => (ColorSpace::RGB, ChannelLayout::Rgb), }; limits.check(w, h, layout, SampleType::U8)?; - // The backend fills whatever the scan no longer describes, so a short scan under a large - // frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is - // measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily - // without describing one more pixel. - if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes { - return Err(err("the truncated scan is too short for the frame it declares")); + // The backend fills whatever the scan no longer describes, and allocates the whole output + // once a scan holds a byte, so a short scan under a large frame is a gigabyte of synthesized + // output from a few kilobytes of file (#8597), truncated or not. The budget is the scan data + // itself, not `bytes.len()`, which APP padding can inflate without describing one more pixel; + // where the walk stops early on an odd layout, what it couldn't read counts as scan data. + if u64::from(w) * u64::from(h) / MAX_PIXELS_PER_BYTE > scan_bytes { + let what = if end == DataEnd::Truncated { "the truncated scan" } else { "the scan data" }; + return Err(err(format!("{what} is too short for the frame it declares"))); } dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs)); let mut px = dec.decode().map_err(err)?; @@ -552,7 +562,7 @@ mod tests { assert_eq!(data_end(&padded).0, DataEnd::Truncated); // Well above the frame's pixels / budget, yet far above the scan data: the old // file-length bound admitted this frame. - assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small"); + assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_PIXELS_PER_BYTE) as usize, "padding too small"); assert!(crate::decode_as(Format::Jpeg, &padded).is_ok()); let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); let mut huge = padded.clone(); @@ -561,6 +571,57 @@ mod tests { assert!(e.to_string().contains("too short"), "{e}"); } + /// A complete file is measured on its scan data too: padding can't admit an oversized frame. + /// The same padded file at its real size decodes, and so does an odd layout whose unread tail + /// counts as scan data. + #[test] + fn app_padding_cannot_admit_an_oversized_complete_frame() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); + let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let fill = [b'p'; 65533]; + let mut padded = full[..2].to_vec(); + for _ in 0..4 { + padded.extend_from_slice(&[0xFF, 0xFE]); + padded.extend_from_slice(&0xFFFFu16.to_be_bytes()); + padded.extend_from_slice(&fill); + } + padded.extend_from_slice(&full[2..]); + let (end, scanned) = data_end(&padded); + assert_eq!(end, DataEnd::Complete); + assert!(scanned < 1024, "the padding isn't scan data: {scanned}"); + assert!(padded.len() as u64 > u64::from(20_000u32) * 20_000 / MAX_PIXELS_PER_BYTE, "padding too small"); + assert!(crate::decode_as(Format::Jpeg, &padded).is_ok()); + let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); + let mut huge = padded.clone(); + huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + // A stray byte where a marker should be, before the padding: the walk skips it and still + // recognises the padding, so an oversized frame is refused there too. + let mut odd = huge.clone(); + odd.insert(2, 0x00); + let (end, scanned) = data_end(&odd); + assert_eq!(end, DataEnd::Complete); + assert!(scanned < 1024, "the padding after a stray byte isn't scan data: {scanned}"); + let e = crate::decode_as(Format::Jpeg, &odd).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + // Stray bytes never count: 256 KiB of junk where a marker should be (the decoder skips + // it), then the oversized frame's segments, is refused. (Bytes after a scan starts are + // entropy-coded data, any value, and count as such.) + let mut junk = huge[..2].to_vec(); + junk.extend(std::iter::repeat_n(0x00u8, 4 * 65536)); + junk.extend_from_slice(&huge[2..]); + let (end, scanned) = data_end(&junk); + assert_eq!(end, DataEnd::Complete); + assert!(scanned < 1024, "junk isn't scan data: {scanned}"); + let e = crate::decode_as(Format::Jpeg, &junk).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + // Junk with no marker after it: no image data at all. + let mut tail = vec![0xFF, 0xD8]; + tail.extend(std::iter::repeat_n(0x00u8, 4096)); + assert_eq!(data_end(&tail), (DataEnd::Empty, 0)); + } + /// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker. #[test] fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() { diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7fbbbc69db..e09115416c 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -419,7 +419,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression } let lvl = match level { PngCompression::None => flate2::Compression::none(), - PngCompression::Fast => flate2::Compression::fast(), + PngCompression::Fast => FAST, PngCompression::Default => flate2::Compression::default(), PngCompression::Best => flate2::Compression::best(), }; @@ -428,6 +428,11 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression z.finish().map_err(|e| CodecError::encode(F, e)) } +/// The deflate level of [`PngCompression::Fast`]: zlib-rs level 1 writes static Huffman blocks +/// only (20-30% bigger on filtered photos); level 3 is the lowest that is never bigger than +/// miniz's level 1 was, at about twice its time. +const FAST: flate2::Compression = flate2::Compression::new(3); + /// Images at least this large (bytes) are filtered and deflated on all cores. const PARALLEL_MIN_BYTES: usize = 4 << 20; @@ -456,7 +461,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1); let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect(); let lvl = match level { - PngCompression::Fast => flate2::Compression::fast(), + PngCompression::Fast => FAST, PngCompression::Best => flate2::Compression::best(), _ => flate2::Compression::default(), }; @@ -494,7 +499,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::() + 6); // zlib header: deflate, 32 K window; FLEVEL only advertises the effort. out.extend_from_slice(match level { - PngCompression::Fast => &[0x78, 0x01], + PngCompression::Fast => &[0x78, 0x5E], PngCompression::Best => &[0x78, 0xDA], _ => &[0x78, 0x9C], }); @@ -509,7 +514,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio /// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band. /// The output buffer is sized past deflate's worst case so that one call takes the whole band -/// and completes the flush: when a sync flush fills the buffer, miniz_oxide (flate2's Rust +/// and completes the flush: when a sync flush fills the buffer, miniz_oxide (flate2's former Rust /// backend) can return before compressing the last lookahead bytes, and the next call only /// drains the pending output, so a resumed flush silently drops data. `None` (the caller then /// uses the serial encoder) if the single call did not finish. diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 1db3b0c44c..e3ccfbc952 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -27,7 +27,7 @@ //! (see [`writes_little_endian`]), which the layer data must match. use std::borrow::Cow; -use std::io::{self, Cursor, Read, Seek, SeekFrom}; +use std::io::{self, Read, Seek, SeekFrom}; use half::f16; use tiff::decoder::{ChunkType, Decoder}; diff --git a/crates/codecs/src/codecs/tiff/bands.rs b/crates/codecs/src/codecs/tiff/bands.rs index fe35cc98d2..8eaeadf8a6 100644 --- a/crates/codecs/src/codecs/tiff/bands.rs +++ b/crates/codecs/src/codecs/tiff/bands.rs @@ -1,5 +1,5 @@ use super::decode::{Layout, Patched, Src, is_eof}; -use super::*; +use super::{CodecError, Decoder, SampleType, err, f16, map_err}; /// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and /// PhotoCraft's (8 bits: `a + 128`; 16 bits: `(a + 128) · 257`). In the file, CIELab holds them diff --git a/crates/codecs/src/codecs/tiff/decode.rs b/crates/codecs/src/codecs/tiff/decode.rs index 4b5d38cb59..0872739f3e 100644 --- a/crates/codecs/src/codecs/tiff/decode.rs +++ b/crates/codecs/src/codecs/tiff/decode.rs @@ -1,6 +1,9 @@ use super::bands::{convert_ab, decode_bands, extra_sample_indices, split_extra_samples, zeroed}; use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags}; -use super::*; +use super::{ + ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag, + camera_raw, err, ifd, io, map_err, tiff_exif, +}; /// Decodes the page Photoshop would open: the first full-resolution directory. pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { @@ -235,6 +238,112 @@ pub(super) fn is_eof(e: &tiff::TiffError) -> bool { matches!(e, tiff::TiffError::IoError(io) if io.kind() == io::ErrorKind::UnexpectedEof) } +/// The sample type of a (SampleFormat, BitsPerSample) pair. +fn sample_src(format: u64, bits: u64) -> Result { + Ok(match (format, bits) { + (1, 1 | 2 | 4) => Src::Bits(bits as u8), + (1, 8) => Src::U8, + (1, 16) => Src::U16, + (1, 32) => Src::U32, + (1, 64) => Src::U64, + (3, 16) => Src::F16, + (3, 32) => Src::F32, + (3, 64) => Src::F64, + (2, _) => return Err(CodecError::unsupported(F, "signed integer samples")), + _ => return Err(CodecError::unsupported(F, format!("{bits}-bit samples (sample format {format})"))), + }) +} + +/// The ColorMap of a palette image as RGB entries. +fn read_palette(f: &File<'_>, dir: &Ifd, spp: u64, bits: u64, src: Src) -> Result, CodecError> { + if spp != 1 || !matches!(src, Src::Bits(_) | Src::U8 | Src::U16) { + return Err(CodecError::unsupported(F, format!("palette image with {spp} samples of {bits} bits"))); + } + let n = 1u64 << bits; + let map = dir.get(ifd::TAG_COLOR_MAP).filter(|e| e.ty == 3 && e.count == 3 * n).ok_or_else(|| err("palette image without a valid ColorMap"))?; + let at = |i: u64| f.uint(map, i).and_then(|v| u16::try_from(v).ok()); + let table: Option> = (0..n).map(|i| Some([at(i)?, at(n + i)?, at(2 * n + i)?])).collect(); + table.ok_or_else(|| err("palette ColorMap cut off")) +} + +/// The layout of a multiband read that was asked for every sample. +fn multiband_layout(photometric: u64, has_alpha: bool, gray_alpha: bool) -> ChannelLayout { + match (photometric, has_alpha) { + (2 | 8 | 9, true) => ChannelLayout::Rgba, + (2 | 8 | 9, false) => ChannelLayout::Rgb, + (5, true) => ChannelLayout::CmykA, + (5, false) => ChannelLayout::Cmyk, + _ if gray_alpha => ChannelLayout::GrayA, + _ => ChannelLayout::Gray, + } +} + +/// The chunk tables must match the geometry. Checked in 64 bits, before the `tiff` crate does +/// the same arithmetic in narrower types. +fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, planar: bool, tiled: bool) -> Result<(u64, u64), CodecError> { + let need = |tag: u16, name: &str| f.tag_uint(dir, tag).ok_or_else(|| err(format!("missing or unreadable {name}"))); + let (cw, ch) = if tiled { + let tw = need(ifd::TAG_TILE_WIDTH, "TileWidth")?; + let tl = need(ifd::TAG_TILE_LENGTH, "TileLength")?; + (tw, tl) + } else { + (u64::from(w), f.tag_uint(dir, ifd::TAG_ROWS_PER_STRIP).unwrap_or(u64::from(h)).min(u64::from(h))) + }; + if cw == 0 || ch == 0 { + return Err(err("zero tile or strip size")); + } + // The chunk tables must match the geometry. Checked here, in 64 bits, before the `tiff` + // crate does the same arithmetic in narrower types. + let planes_in_file = if planar { spp } else { 1 }; + let expected = u64::from(w) + .div_ceil(cw) + .checked_mul(u64::from(h).div_ceil(ch)) + .and_then(|n| n.checked_mul(planes_in_file)) + .filter(|&n| n <= u64::from(u32::MAX)) + .ok_or_else(|| err("too many strips or tiles"))?; + let (offsets_tag, counts_tag) = if tiled { (324, 325) } else { (273, 279) }; + let counts = (dir.get(offsets_tag).map(|e| e.count), dir.get(counts_tag).map(|e| e.count)); + if counts != (Some(expected), Some(expected)) { + return Err(err(format!("{expected} strips or tiles expected, the tables list {counts:?}"))); + } + Ok((cw, ch)) +} + +/// Byte patches the `tiff` crate reads through: the directory is seen as the last of its chain, +/// and PhotometricInterpretation is handed over as black-is-zero (or RGB for Lab) when asked to. +fn header_patches( + f: &File<'_>, + dir: &Ifd, + photometric_entry: Option, + patch_photometric: bool, + lab_only: bool, +) -> Result)>, CodecError> { + let mut patches: Vec<(u64, Vec)> = Vec::new(); + let encode_u = |v: u64, len: usize| -> Vec { + let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; + if f.le { b.get(..len).map(<[u8]>::to_vec).unwrap_or_default() } else { b.get(8 - len..).map(<[u8]>::to_vec).unwrap_or_default() } + }; + if f.big { + patches.push((8, encode_u(dir.at, 8))); + } else { + let at = u32::try_from(dir.at).map_err(|_| err("directory offset out of range"))?; + patches.push((4, encode_u(u64::from(at), 4))); + } + // The directory is seen as the last of its chain: the chain was walked already, and a + // cycle through it must not fail this page. + patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }])); + if patch_photometric { + let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?; + let len = match e.ty { + 3 if e.count == 1 => 2, + 4 if e.count == 1 => 4, + _ => return Err(err("malformed PhotometricInterpretation")), + }; + patches.push((e.value_at, encode_u(if lab_only { 2 } else { 1 }, len))); + } + Ok(patches) +} + /// Decodes the pixels of one directory. fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result { // The values the `tiff` crate reads must exist: a count past the end of the file is @@ -263,59 +372,13 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result Src::Bits(bits as u8), - (1, 8) => Src::U8, - (1, 16) => Src::U16, - (1, 32) => Src::U32, - (1, 64) => Src::U64, - (3, 16) => Src::F16, - (3, 32) => Src::F32, - (3, 64) => Src::F64, - (2, _) => return Err(CodecError::unsupported(F, "signed integer samples")), - _ => return Err(CodecError::unsupported(F, format!("{bits}-bit samples (sample format {format})"))), - }; - + let (cw, ch) = check_chunk_tables(f, dir, (w, h), spp, planar, tiled)?; + let src = sample_src(format, bits)?; // WhiteIsZero and palette images are handed to the `tiff` crate as BlackIsZero: it then // returns the stored samples, and the interpretation is applied here (for every depth and // with alpha, which the crate's own inversion does not cover). let invert = photometric == 0; - let palette = if photometric == 3 { - if spp != 1 || !matches!(src, Src::Bits(_) | Src::U8 | Src::U16) { - return Err(CodecError::unsupported(F, format!("palette image with {spp} samples of {bits} bits"))); - } - let n = 1u64 << bits; - let map = dir.get(ifd::TAG_COLOR_MAP).filter(|e| e.ty == 3 && e.count == 3 * n).ok_or_else(|| err("palette image without a valid ColorMap"))?; - let at = |i: u64| f.uint(map, i).and_then(|v| u16::try_from(v).ok()); - let table: Option> = (0..n).map(|i| Some([at(i)?, at(n + i)?, at(2 * n + i)?])).collect(); - Some(table.ok_or_else(|| err("palette ColorMap cut off"))?) - } else { - None - }; + let palette = if photometric == 3 { Some(read_palette(f, dir, spp, bits, src)?) } else { None }; // CIE L*a*b*: CIELab (8; a* and b* signed) and ICCLab (9; a* and b* offset) are handed to the // `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding // (`convert_ab`). @@ -337,29 +400,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result color.saturating_add(usize::from(alpha_at.is_some())); let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused)); - let mut patches: Vec<(u64, Vec)> = Vec::new(); - let encode_u = |v: u64, len: usize| -> Vec { - let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; - if f.le { b.get(..len).map(<[u8]>::to_vec).unwrap_or_default() } else { b.get(8 - len..).map(<[u8]>::to_vec).unwrap_or_default() } - }; - if f.big { - patches.push((8, encode_u(dir.at, 8))); - } else { - let at = u32::try_from(dir.at).map_err(|_| err("directory offset out of range"))?; - patches.push((4, encode_u(u64::from(at), 4))); - } - // The directory is seen as the last of its chain: the chain was walked already, and a - // cycle through it must not fail this page. - patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }])); - if invert || palette.is_some() || all_samples || lab { - let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?; - let len = match e.ty { - 3 if e.count == 1 => 2, - 4 if e.count == 1 => 4, - _ => return Err(err("malformed PhotometricInterpretation")), - }; - patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len))); - } + let patches = header_patches(f, dir, photometric_entry, invert || palette.is_some() || all_samples || lab, lab && !all_samples)?; let tl = { let mut l = tiff::decoder::Limits::default(); @@ -380,29 +421,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Gray, 1), tiff::ColorType::Multiband { num_samples, .. } if all_samples => { // Lab reads as RGB here and is relabelled below. - let layout = match photometric { - 2 | 8 | 9 => { - if alpha_at.is_some() { - ChannelLayout::Rgba - } else { - ChannelLayout::Rgb - } - } - 5 => { - if alpha_at.is_some() { - ChannelLayout::CmykA - } else { - ChannelLayout::Cmyk - } - } - _ => { - if gray_alpha { - ChannelLayout::GrayA - } else { - ChannelLayout::Gray - } - } - }; + let layout = multiband_layout(photometric, alpha_at.is_some(), gray_alpha); (layout, usize::from(num_samples)) } tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => { diff --git a/crates/codecs/src/codecs/tiff/encode.rs b/crates/codecs/src/codecs/tiff/encode.rs index d71c32efde..a0b7539b86 100644 --- a/crates/codecs/src/codecs/tiff/encode.rs +++ b/crates/codecs/src/codecs/tiff/encode.rs @@ -1,6 +1,11 @@ use super::bands::convert_ab; use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS}; -use super::*; +use super::{ + ChannelLayout, CmykA16, CmykA32F, CodecError, Cow, EncodeOptions, ExifDirs, GrayA8, GrayA16, GrayA32F, Image, Lab8, Lab16, LabA8, LabA16, + PhotometricInterpretation, Plan, Rational, ResolutionUnit, SampleFormat, SampleType, Tag, TiffColorType, TiffCompression, TiffEncoder, TiffKind, + TiffKindBig, TiffKindStandard, TiffValue, Type, colortype, io, tiff_exif, +}; +use std::io::Cursor; // --------------------------------------------------------------------------- // Encoding diff --git a/crates/codecs/src/codecs/tiff/tags.rs b/crates/codecs/src/codecs/tiff/tags.rs index df110f777a..a41b154137 100644 --- a/crates/codecs/src/codecs/tiff/tags.rs +++ b/crates/codecs/src/codecs/tiff/tags.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Format, Tag}; pub(super) const F: Format = Format::Tiff; diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 81f4a369ca..0cb0bd4202 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -93,7 +93,25 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(1 << 28)), + _ => declared, + }; + if !matches!(f, Format::Avif | Format::Hdr) && !(f == Format::Bmp && bmp_is_rle(bytes)) { + Limits::check_plausible(declared, bytes.len(), 1 << 14)?; + } Ok(dec) } @@ -123,9 +141,36 @@ pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u3 Ok((w, h, dec.exif_metadata().ok().flatten())) } +/// Whether a BMP's `biCompression` is RLE8 or RLE4 (only headers of 40 bytes or more have one). +fn bmp_is_rle(b: &[u8]) -> bool { + let u32_at = |at: usize| b.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + u32_at(14).is_some_and(|n| n >= 40) && matches!(u32_at(30), Some(1 | 2)) +} + +/// The width and height of a GIF's first image descriptor, if the blocks reach one. +fn gif_first_frame(b: &[u8]) -> Option<(u32, u32)> { + let mut at = None; + gif_walk(b, |i| { + at = Some(i); + false + }); + let u16_at = |at: usize| b.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + // Descriptor: separator, left, top, width, height (little-endian u16s), packed fields. + let i = at?; + Some((u32::from(u16_at(i + 5)?), u32::from(u16_at(i + 7)?))) +} + /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. fn gif_more_frames(b: &[u8]) -> Option { + let (frames, total) = gif_walk(b, |_| true); + (frames > 1).then_some(DecodeWarning::MoreFrames { total }) +} + +/// Walks a GIF's blocks without decompressing any, handing `frame` the offset of each image +/// descriptor until it returns false. Gives the descriptors seen, and the frame count if the +/// blocks reached the trailer. +fn gif_walk(b: &[u8], mut frame: impl FnMut(usize) -> bool) -> (u32, Option) { // Size of the colour table a packed-fields byte announces. let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 }; // Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length). @@ -139,7 +184,10 @@ fn gif_more_frames(b: &[u8]) -> Option { } }; // Header (6 bytes), logical screen descriptor (7) and the global colour table. - let mut i = 13 + table(*b.get(10)?); + let Some(&flags) = b.get(10) else { + return (0, None); + }; + let mut i = 13 + table(flags); let mut frames = 0u32; let total = loop { let next = match b.get(i) { @@ -147,6 +195,9 @@ fn gif_more_frames(b: &[u8]) -> Option { Some(0x21) => skip(i + 2), Some(0x2C) => { frames = frames.saturating_add(1); + if !frame(i) { + break None; + } // Descriptor (10 bytes, packed fields last), local colour table, LZW code size. b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags))) } @@ -157,7 +208,7 @@ fn gif_more_frames(b: &[u8]) -> Option { None => break None, } }; - (frames > 1).then_some(DecodeWarning::MoreFrames { total }) + (frames, total) } pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result { diff --git a/crates/codecs/src/codecs/vp8.rs b/crates/codecs/src/codecs/vp8.rs index ba75bdd3e9..a24fb727cc 100644 --- a/crates/codecs/src/codecs/vp8.rs +++ b/crates/codecs/src/codecs/vp8.rs @@ -19,7 +19,11 @@ mod transform; use bool_encoder::BoolEncoder; use predict::{Plane, SubEdges, predict_block, predict_sub}; -use tables::*; +use tables::{ + AC_QLOOKUP, B_DC_PRED, B_HE_PRED, B_PRED, B_TM_PRED, B_VE_PRED, BMODE_TREE, CAT_BASE, COEFF_BANDS, COEFF_TREE, COEFF_UPDATE_PROBS, DC_PRED, DC_QLOOKUP, + DCT_0, DCT_1, DCT_2, DCT_3, DCT_4, DCT_CAT1, DCT_CAT2, DCT_CAT3, DCT_CAT4, DCT_CAT5, DCT_CAT6, DCT_EOB, DEFAULT_COEFF_PROBS, H_PRED, KF_BMODE_PROBS, + KF_UV_MODE_PROB, KF_YMODE_PROB, KF_YMODE_TREE, PCAT, TM_PRED, UV_MODE_TREE, V_PRED, ZIGZAG, +}; use transform::{fdct, fwht, idct, iwht}; /// The largest dimension a VP8 frame header can hold (14 bits). @@ -353,6 +357,65 @@ impl<'a> Encoder<'a> { .sum() } + /// Chroma of one macroblock: one mode for both planes; returns it with the U and V blocks. + fn encode_chroma(&mut self, mbx: usize, mby: usize) -> (u8, Vec, Vec) { + let (cx0, cy0) = (mbx * 8, mby * 8); + let mut src_u = [0i32; 64]; + let mut src_v = [0i32; 64]; + for r in 0..8 { + for c in 0..8 { + src_u[r * 8 + c] = self.src_c(&self.src.u, cx0 + c, cy0 + r); + src_v[r * 8 + c] = self.src_c(&self.src.v, cx0 + c, cy0 + r); + } + } + let mut uv_mode = DC_PRED; + let mut uv_best = u64::MAX; + let mut pred_u = [0u8; 64]; + let mut pred_v = [0u8; 64]; + let mut pu = [0u8; 64]; + let mut pv = [0u8; 64]; + for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] { + predict_block(&self.rec_u, cx0, cy0, 8, mode, &mut pu); + predict_block(&self.rec_v, cx0, cy0, 8, mode, &mut pv); + let e = Self::sse(&pu, &src_u) + Self::sse(&pv, &src_v); + if e < uv_best { + uv_best = e; + uv_mode = mode; + pred_u = pu; + pred_v = pv; + } + } + let mut u_blocks = Vec::with_capacity(4); + let mut v_blocks = Vec::with_capacity(4); + for (plane_src, pred, blocks, is_u) in [(&src_u, &pred_u, &mut u_blocks, true), (&src_v, &pred_v, &mut v_blocks, false)] { + for b in 0..4 { + let mut residual = [0i32; 16]; + for r in 0..4 { + for c in 0..4 { + let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c; + residual[r * 4 + c] = plane_src[i] - i32::from(pred[i]); + } + } + let (quantized, dequantized) = Self::code_block(&residual, self.q.uv_dc, self.q.uv_ac, true); + let recon = idct(&dequantized); + let (bx, by) = (cx0 + (b & 1) * 4, cy0 + (b >> 1) * 4); + for r in 0..4 { + for c in 0..4 { + let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c; + let value = (i32::from(pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8; + if is_u { + self.rec_u.set(bx + c, by + r, value); + } else { + self.rec_v.set(bx + c, by + r, value); + } + } + } + blocks.push(Block::from_raster(&quantized, 0)); + } + } + (uv_mode, u_blocks, v_blocks) + } + // Sub-block indices address several arrays by geometry; ranges read better than iterators here. #[allow(clippy::needless_range_loop)] fn encode_macroblock(&mut self, mbx: usize, mby: usize) { @@ -476,61 +539,7 @@ impl<'a> Encoder<'a> { (best_mode, Some(Block::from_raster(&y2_q, 0)), blocks) }; - // Chroma: one mode for both planes. - let (cx0, cy0) = (mbx * 8, mby * 8); - let mut src_u = [0i32; 64]; - let mut src_v = [0i32; 64]; - for r in 0..8 { - for c in 0..8 { - src_u[r * 8 + c] = self.src_c(&self.src.u, cx0 + c, cy0 + r); - src_v[r * 8 + c] = self.src_c(&self.src.v, cx0 + c, cy0 + r); - } - } - let mut uv_mode = DC_PRED; - let mut uv_best = u64::MAX; - let mut pred_u = [0u8; 64]; - let mut pred_v = [0u8; 64]; - let mut pu = [0u8; 64]; - let mut pv = [0u8; 64]; - for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] { - predict_block(&self.rec_u, cx0, cy0, 8, mode, &mut pu); - predict_block(&self.rec_v, cx0, cy0, 8, mode, &mut pv); - let e = Self::sse(&pu, &src_u) + Self::sse(&pv, &src_v); - if e < uv_best { - uv_best = e; - uv_mode = mode; - pred_u = pu; - pred_v = pv; - } - } - let mut u_blocks = Vec::with_capacity(4); - let mut v_blocks = Vec::with_capacity(4); - for (plane_src, pred, blocks, is_u) in [(&src_u, &pred_u, &mut u_blocks, true), (&src_v, &pred_v, &mut v_blocks, false)] { - for b in 0..4 { - let mut residual = [0i32; 16]; - for r in 0..4 { - for c in 0..4 { - let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c; - residual[r * 4 + c] = plane_src[i] - i32::from(pred[i]); - } - } - let (quantized, dequantized) = Self::code_block(&residual, self.q.uv_dc, self.q.uv_ac, true); - let recon = idct(&dequantized); - let (bx, by) = (cx0 + (b & 1) * 4, cy0 + (b >> 1) * 4); - for r in 0..4 { - for c in 0..4 { - let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c; - let value = (i32::from(pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8; - if is_u { - self.rec_u.set(bx + c, by + r, value); - } else { - self.rec_v.set(bx + c, by + r, value); - } - } - } - blocks.push(Block::from_raster(&quantized, 0)); - } - } + let (uv_mode, u_blocks, v_blocks) = self.encode_chroma(mbx, mby); let skip = y2.as_ref().is_none_or(|b| !b.has_coeffs()) && y_blocks.iter().all(|b| !b.has_coeffs()) diff --git a/crates/codecs/src/codecs/vp8/predict.rs b/crates/codecs/src/codecs/vp8/predict.rs index 47319fdd7a..7ddea04238 100644 --- a/crates/codecs/src/codecs/vp8/predict.rs +++ b/crates/codecs/src/codecs/vp8/predict.rs @@ -2,7 +2,9 @@ //! sub-block modes. Predictions read the frame's *unfiltered* reconstruction, which is what the //! decoder predicts from too (the loop filter runs on the finished frame). -use super::tables::*; +#[cfg(test)] +use super::tables::{B_DC_PRED, DC_PRED}; +use super::tables::{B_HD_PRED, B_HE_PRED, B_HU_PRED, B_LD_PRED, B_RD_PRED, B_TM_PRED, B_VE_PRED, B_VL_PRED, B_VR_PRED, H_PRED, TM_PRED, V_PRED}; /// A plane of the reconstruction with the virtual border VP8 defines: the row above the frame /// reads as 127, the column left of it as 129, and the pixel above-left of the frame as 127. diff --git a/crates/codecs/tests/it/limits.rs b/crates/codecs/tests/it/limits.rs index c1873771f6..536e303039 100644 --- a/crates/codecs/tests/it/limits.rs +++ b/crates/codecs/tests/it/limits.rs @@ -398,6 +398,12 @@ fn pnm_header_alone_reserves_nothing_without_limits() { } } +#[test] +fn hdr_header_sizes_that_overflow_u64_are_errors_without_limits() { + let b = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2147483648 +X 2147483648\n"; + assert!(decode_with(b, &opts(Limits::none())).is_err()); +} + #[test] fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() { let m = u32::MAX; @@ -456,3 +462,179 @@ fn limits_check_api() { assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err()); assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok()); } + +/// #8597: a small file whose header declares about 3 GB, inside the default limits, is refused +/// by the size-against-file check before the decoder allocates its output. +fn refused_as_implausible(f: Format, b: &[u8]) { + match decode(b) { + Err(CodecError::LimitExceeded(m)) => assert!(m.contains("cannot hold"), "{f:?}: {m}"), + other => panic!("{f:?}: expected an implausible-size refusal, got {:?}", other.map(|i| (i.width(), i.height()))), + } +} + +fn find(b: &[u8], needle: &[u8]) -> usize { + b.windows(needle.len()).position(|w| w == needle).unwrap() +} + +#[test] +fn jpeg_header_declaring_gigabytes_needs_a_plausible_file_size() { + let mut b = encoded(Format::Jpeg); + // SOF0: marker, length, precision, then height and width (big-endian). + let sof = find(&b, &[0xFF, 0xC0]); + b[sof + 5..sof + 7].copy_from_slice(&16000u16.to_be_bytes()); + b[sof + 7..sof + 9].copy_from_slice(&65000u16.to_be_bytes()); + // The decoder's own scan-size guard refuses it (one refusal path for truncated and complete + // files alike), before the backend allocates the frame. + match decode(&b) { + Err(e) => assert!(e.to_string().contains("too short for the frame"), "{e}"), + Ok(i) => panic!("decoded a {}×{} frame from {} bytes", i.width(), i.height(), b.len()), + } +} + +#[test] +fn image_crate_headers_declaring_gigabytes_need_a_plausible_file_size() { + let side = 30000u32; + let mut bmp = encoded(Format::Bmp); + bmp[18..22].copy_from_slice(&(side as i32).to_le_bytes()); + bmp[22..26].copy_from_slice(&(side as i32).to_le_bytes()); + refused_as_implausible(Format::Bmp, &bmp); + let mut qoi = encoded(Format::Qoi); + // The qoi crate refuses more than 400 M pixels itself, so 20000 x 20000 (1.6 GB). + qoi[4..8].copy_from_slice(&20000u32.to_be_bytes()); + qoi[8..12].copy_from_slice(&20000u32.to_be_bytes()); + refused_as_implausible(Format::Qoi, &qoi); + let mut gif = encoded(Format::Gif); + gif[6..8].copy_from_slice(&(side as u16).to_le_bytes()); + gif[8..10].copy_from_slice(&(side as u16).to_le_bytes()); + refused_as_implausible(Format::Gif, &gif); + // The frame too. Descriptor: separator, left, top, width, height. + let frame = find(&gif, &[0x2C, 0, 0, 0, 0, 64, 0, 48, 0]); + gif[frame + 5..frame + 7].copy_from_slice(&(side as u16).to_le_bytes()); + gif[frame + 7..frame + 9].copy_from_slice(&(side as u16).to_le_bytes()); + refused_as_implausible(Format::Gif, &gif); +} + +/// GIF89a with a two-colour global table and one frame at the top left holding no pixels or +/// one pixel (LZW minimum code size 2: clear, index 0, end of information). +fn tiny_gif(screen: (u16, u16), frame: (u16, u16)) -> Vec { + let mut b = b"GIF89a".to_vec(); + for v in [screen.0, screen.1] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(&[0x80, 0, 0, 0, 0, 0, 255, 255, 255, 0x2C, 0, 0, 0, 0]); + for v in [frame.0, frame.1] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(&[0, 2, 2, 0x44, 0x01, 0, 0x3B]); + b +} + +#[test] +fn a_gif_frame_much_smaller_than_its_screen_still_decodes() { + let img = decode(&tiny_gif((4000, 4000), (1, 1))).unwrap(); + assert_eq!((img.width(), img.height()), (4000, 4000)); +} + +#[test] +fn a_tiny_gif_frame_does_not_admit_a_gigabyte_screen() { + refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (1, 1))); + // 324 MB of screen: past the 256 MiB that needs no data. + refused_as_implausible(Format::Gif, &tiny_gif((9000, 9000), (1, 1))); + refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (0, 0))); + // A frame bigger than its screen is decoded at its own size. + refused_as_implausible(Format::Gif, &tiny_gif((100, 100), (40000, 40000))); +} + +#[test] +fn an_rle_bmp_ending_early_still_decodes() { + // 4000x4000 RLE8 whose only data is the end-of-bitmap escape: every pixel is left at index 0. + let data = 14 + 40 + 1024; + let mut b = b"BM".to_vec(); + for v in [data + 2, 0, data, 40, 4000, 4000] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8u16.to_le_bytes()); + for v in [1, 2, 0, 0, 0, 0] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.resize(data, 0); + b.extend_from_slice(&[0, 1]); + let img = decode(&b).unwrap(); + assert_eq!((img.width(), img.height()), (4000, 4000)); +} + +#[test] +fn pcx_header_declaring_gigabytes_needs_a_plausible_file_size() { + // 24-bit (three 8-bit planes) 65535 x 16384, with one row of data. + let mut b = vec![0u8; 128]; + b[0] = 0x0a; + b[1] = 5; + b[2] = 1; + b[3] = 8; + b[8..10].copy_from_slice(&65534u16.to_le_bytes()); + b[10..12].copy_from_slice(&16383u16.to_le_bytes()); + b[65] = 3; + b[66..68].copy_from_slice(&65535u16.to_le_bytes()); + b[68..70].copy_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&[0xff, 0]); + // PCX's own minimum-packet check refuses it before the output is allocated. + match decode(&b) { + Err(CodecError::Malformed { message, .. }) => assert!(message.contains("too short"), "{message}"), + other => panic!("expected a too-short refusal, got {:?}", other.map(|i| (i.width(), i.height()))), + } +} + +#[test] +fn xpm_rows_shorter_than_the_header_declares_are_refused_before_allocating() { + let b = b"/* XPM */\nstatic char *x[] = {\n\"30000 30000 1 1\",\n\". c #000000\",\n\".\",\n};\n".to_vec(); + match decode(&b) { + Err(CodecError::Malformed { .. }) => {} + other => panic!("expected a malformed-row error, got {:?}", other.map(|i| (i.width(), i.height()))), + } +} + +#[test] +fn exr_header_declaring_gigabytes_needs_a_plausible_file_size() { + let mut b = encoded(Format::OpenExr); + // dataWindow: name, type, size, then xMin, yMin, xMax, yMax (little-endian i32). + let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4; + b[at + 8..at + 12].copy_from_slice(&15999i32.to_le_bytes()); + b[at + 12..at + 16].copy_from_slice(&15999i32.to_le_bytes()); + refused_as_implausible(Format::OpenExr, &b); +} + +#[test] +fn a_dwab_exr_gets_a_looser_file_size_bound() { + // DWA passes 2^16:1 (a flat 8K frame with 12 alpha-scheme mattes is about 30 KB), so a DWAB + // part may declare 4 times more than a ZIP part from the same file, and no more. + let img = synth(1, 1, ChannelLayout::Rgba, SampleType::U8, 1, 0.1); + let mut b = encode(&img, Format::OpenExr, &EncodeOptions::default()).unwrap(); + let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4; + let c = find(&b, b"compression\0compression\0") + b"compression\0compression\0".len() + 4; + // RGBA as f32: 16 bytes a pixel against a file of a few hundred bytes. + let size = |b: &mut Vec, side: i32| { + b[at + 8..at + 12].copy_from_slice(&(side - 1).to_le_bytes()); + b[at + 12..at + 16].copy_from_slice(&(side - 1).to_le_bytes()); + }; + let refused = |b: &[u8]| matches!(exr_info(b, &Limits::default()), Err(CodecError::LimitExceeded(m)) if m.contains("cannot hold")); + size(&mut b, 2048); + b[c] = 3; // ZIP + assert!(refused(&b), "{} bytes", b.len()); + for dwa in [8, 9] { + b[c] = dwa; // DWAA, DWAB + let parts = exr_info(&b, &Limits::default()).unwrap(); + assert_eq!((parts[0].width, parts[0].height), (2048, 2048)); + } + size(&mut b, 4096); + assert!(refused(&b), "{} bytes", b.len()); +} + +#[test] +fn highly_compressible_jpeg_and_gif_still_decode() { + let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap(); + for f in [Format::Jpeg, Format::Gif, Format::Qoi, Format::OpenExr] { + let b = encode(&img, f, &EncodeOptions::default()).unwrap(); + assert_eq!(decode(&b).unwrap().width(), 3000, "{f:?}"); + } +} diff --git a/crates/compose/proptest-regressions/cmyk_tests.txt b/crates/compose/proptest-regressions/cmyk_tests.txt index 2f3a9c3fb8..2498f3013c 100644 --- a/crates/compose/proptest-regressions/cmyk_tests.txt +++ b/crates/compose/proptest-regressions/cmyk_tests.txt @@ -7,3 +7,4 @@ cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] } cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } +cc 33fb5c528e97f31669a82a4634930230f903c97e58ce59fc88fd733ac097089c # shrinks to s = Spec { w: 6, h: 4, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 5, y1: 3 }, [0.0, 1.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }, Group { children: [Pixels { kind: Solid, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 1.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: VividLight, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Text, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: true, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } diff --git a/crates/compose/src/adjust/channels.rs b/crates/compose/src/adjust/channels.rs index 4b7d42b92a..2371754077 100644 --- a/crates/compose/src/adjust/channels.rs +++ b/crates/compose/src/adjust/channels.rs @@ -1,6 +1,6 @@ use super::color::{bayer4, gradient}; use super::tone::{LUT_SIZE, lut, map_rgb, modern_brightness, modern_contrast, modern_lut, posterize_in, tone_luts_depth}; -use super::*; +use super::{Adjustment, Buffer, ColorMode, LEGACY_PIVOT, SampleType, ToneSpace, Transfer, apply_depth, lab_to_srgb, rgb_to_gray, srgb_to_lab}; /// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at /// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel diff --git a/crates/compose/src/adjust/color.rs b/crates/compose/src/adjust/color.rs index f47d1154f0..94a57717ce 100644 --- a/crates/compose/src/adjust/color.rs +++ b/crates/compose/src/adjust/color.rs @@ -1,5 +1,5 @@ use super::tone::LUT_SIZE; -use super::*; +use super::{D50, HueRange, SRGB_TO_XYZ_D50, SampleType, Transfer, XYZ_D50_TO_SRGB, mat3_mul, srgb_to_linear}; /// Hue/Saturation's range edits as three hue-indexed tables (hue shift in degrees, saturation and /// lightness as fractions), sampled at hue `k / (len - 1)` turns. Ranges add up where they overlap. diff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs index 3659748392..5debc98a83 100644 --- a/crates/compose/src/adjust/tone.rs +++ b/crates/compose/src/adjust/tone.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Adjustment, Buffer, ColorMode, CurvePoint, LevelsChannel, SampleType, ToneSpace, lab_to_srgb, srgb_to_lab}; /// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited /// ones extend past it (see [`float_tone_extrapolates`]). diff --git a/crates/compose/src/adjust/tone_tests.rs b/crates/compose/src/adjust/tone_tests.rs index fbd91bf14b..7134d5288f 100644 --- a/crates/compose/src/adjust/tone_tests.rs +++ b/crates/compose/src/adjust/tone_tests.rs @@ -198,7 +198,8 @@ fn color_lookup_with_an_overflowing_stored_size_is_the_identity() { // A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past // the table-length guard and indexed out of bounds. for size in [1 << 22, u32::MAX] { - let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; + let a = + Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false, source: None }; let mut b = ramp(); apply(&a, &mut b); assert_eq!(b.px, ramp().px, "size {size}"); diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 6809c9e865..8ea5a86c6d 100644 --- a/crates/compose/src/advanced.rs +++ b/crates/compose/src/advanced.rs @@ -12,7 +12,9 @@ //! composites onto the result in its own mode, kept inside the base's shape (its alpha with //! masks, times its opacity). //! - **Transparency Shapes Layer off.** The layer's effects and knockout cover the whole layer -//! (its masks still apply) instead of following its transparency. +//! (its masks still apply) instead of following its transparency. In the special eight +//! ([`crate::psblend::fill_is_special`]) the layer's own transparency then blends as Fill does +//! (`blend_layer_into` in `composite.rs`; measured in Photoshop, #5692). //! - **Layer / Vector Mask Hides Effects.** The effects are built from the layer without that //! mask, and the mask is applied to the finished layer and effects. //! @@ -75,7 +77,13 @@ pub fn advanced_active(layer: &Layer, clipped: &[Layer]) -> bool { return false; } let fx = effects::has_effects(layer); - a.knockout != Knockout::None || clips_individually(layer, clipped) || (a.blend_interior && fx) || (!a.transparency_shapes && fx) || hides_effects(layer) + // Transparency Shapes Layer off changes the special eight's blend itself (#5692). + let shapeless_blend = crate::psblend::fill_is_special(layer.blend) && !matches!(layer.content, LayerContent::Adjustment(_)); + a.knockout != Knockout::None + || clips_individually(layer, clipped) + || (a.blend_interior && fx) + || (!a.transparency_shapes && (fx || shapeless_blend)) + || hides_effects(layer) } /// Whether the layer's effects (or knockout) cover the whole layer: Transparency Shapes Layer diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index a0fdf52cb0..608c6188e2 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -555,3 +555,157 @@ fn deep_knockout_reveals_a_localized_background() { check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background"); } } + +/// Transparency Shapes Layer off in the special eight: the layer's own transparency blends as +/// Fill does (#5692). Measured in Photoshop: Background (90, 110, 130), a layer (200, 150, 80) at +/// alpha 30 % (77) and 70 % (179) in columns 0–5 and 6–11, read from the flattened 8-bit PNG. +#[test] +fn transparency_shapes_off_blends_alpha_as_fill() { + const A30: f32 = 77.0 / 255.0; + const A70: f32 = 179.0 / 255.0; + let c8 = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0]; + let px = |c: [u8; 3], a: f32| { + let c = c8(c); + [c[0], c[1], c[2], a] + }; + let make = |depth: SampleType, mode: BlendMode, opacity: f32, fill: f32, tsl: bool| { + let b = c8([90, 110, 130]); + let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2])); + let mut l = solid(&d, "a30", Rect::new(0, 0, 6, 8), px([200, 150, 80], A30)); + l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), px([200, 150, 80], A70))); + l.blend = mode; + l.opacity = opacity; + l.fill_opacity = fill; + l.advanced.transparency_shapes = tsl; + d.layers.push(l); + d + }; + let (o53, f50) = (135.0 / 255.0, 128.0 / 255.0); + // (mode, opacity, fill, off at 30 %, off at 70 %, on at 30 %, on at 70 %) + type Rgb = [u8; 3]; + let measured: [(BlendMode, f32, f32, Rgb, Rgb, Rgb, Rgb); 7] = [ + (BlendMode::ColorDodge, o53, 1.0, [105, 123, 137], [148, 151, 150], [117, 133, 139], [151, 164, 152]), + (BlendMode::ColorBurn, o53, 1.0, [84, 99, 113], [74, 79, 69], [83, 94, 109], [73, 72, 82]), + (BlendMode::LinearDodge, o53, 1.0, [122, 134, 143], [164, 166, 160], [117, 133, 143], [151, 164, 160]), + (BlendMode::HardMix, o53, 1.0, [98, 111, 119], [133, 117, 75], [117, 133, 109], [151, 164, 82]), + (BlendMode::Difference, o53, 1.0, [58, 86, 117], [69, 54, 100], [93, 99, 117], [97, 84, 100]), + (BlendMode::ColorDodge, 1.0, f50, [102, 121, 136], [125, 139, 146], [108, 124, 137], [131, 142, 147]), + (BlendMode::Difference, 1.0, f50, [59, 87, 118], [19, 57, 102], [66, 87, 118], [34, 57, 102]), + ]; + for depth in DEPTHS { + for (mode, opacity, fill, off30, off70, on30, on70) in measured { + // A 32-bit document's Linear Dodge doesn't clip at 1 (psblend::HDR). + if depth == SampleType::F32 && mode == BlendMode::LinearDodge { + continue; + } + let what = format!("{mode:?} opacity {opacity} fill {fill}"); + let off = make(depth, mode, opacity, fill, false); + check(&off, 2, 4, px(off30, 1.0), &format!("{what}, off, alpha 30 %")); + check(&off, 9, 4, px(off70, 1.0), &format!("{what}, off, alpha 70 %")); + let on = make(depth, mode, opacity, fill, true); + check(&on, 2, 4, px(on30, 1.0), &format!("{what}, on, alpha 30 %")); + check(&on, 9, 4, px(on70, 1.0), &format!("{what}, on, alpha 70 %")); + assert!(advanced_active(&off.layers[1], &[]), "{what}: the GPU leaves it to the CPU"); + } + // Modes outside the special eight look the same either way, and stay on the plain path. + for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay] { + let (off, on) = (make(depth, mode, o53, f50, false), make(depth, mode, o53, f50, true)); + assert_eq!(render(&off, off.bounds()).px, render(&on, on.bounds()).px, "{mode:?}"); + assert!(!advanced_active(&off.layers[1], &[]), "{mode:?}"); + } + } +} + +/// A mask is not the layer's transparency: Photoshop draws an opaque layer under a 30 % mask the +/// same with the option on or off (measured: Color Dodge, Hard Mix, Difference and the rest of +/// the special eight, every Opacity and Fill tried). Over a half-transparent backdrop and for an +/// isolated group the alpha still blends as Fill (measured: Color Dodge at 53 %). +#[test] +fn transparency_shapes_off_ignores_masks_and_reaches_groups() { + let c8 = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0]; + let px = |c: [u8; 3], a: f32| { + let c = c8(c); + [c[0], c[1], c[2], a] + }; + let o53 = 135.0 / 255.0; + for depth in DEPTHS { + for mode in [BlendMode::ColorDodge, BlendMode::HardMix, BlendMode::Difference] { + let render_with = |tsl: bool| { + let b = c8([90, 110, 130]); + let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2])); + let mut l = solid(&d, "l", full(&d), px([200, 150, 80], 1.0)); + l.blend = mode; + l.opacity = o53; + l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() }); + l.advanced.transparency_shapes = tsl; + d.layers.push(l); + render(&d, d.bounds()).px + }; + let (off, on) = (render_with(false), render_with(true)); + assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency"); + } + // Color Dodge at 53 % over a backdrop of alpha 50 % (128): (118, 127, 129, 148) at + // alpha 30 %, (162, 151, 131, 175) at 70 %. + let mut d = doc(depth, false); + d.layers.push(solid(&d, "under", full(&d), px([90, 110, 130], 128.0 / 255.0))); + let mut l = solid(&d, "a30", Rect::new(0, 0, 6, 8), px([200, 150, 80], 77.0 / 255.0)); + l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), px([200, 150, 80], 179.0 / 255.0))); + l.blend = BlendMode::ColorDodge; + l.opacity = o53; + l.advanced.transparency_shapes = false; + d.layers.push(l.clone()); + check(&d, 2, 4, px([118, 127, 129], 148.0 / 255.0), "half-transparent backdrop, alpha 30 %"); + check(&d, 9, 4, px([162, 151, 131], 175.0 / 255.0), "half-transparent backdrop, alpha 70 %"); + // An isolated Color Dodge group at 53 % holding the Normal layer: the group's alpha blends + // as Fill, as a layer's does. + let b = c8([90, 110, 130]); + let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2])); + l.blend = BlendMode::Normal; + l.opacity = 1.0; + l.advanced.transparency_shapes = true; + let mut g = Layer::group("g", vec![l]); + g.blend = BlendMode::ColorDodge; + g.opacity = o53; + g.advanced.transparency_shapes = false; + d.layers.push(g); + check(&d, 2, 4, px([105, 123, 137], 1.0), "group, alpha 30 %"); + check(&d, 9, 4, px([148, 151, 150], 1.0), "group, alpha 70 %"); + } +} + +/// A clipped knockout in a Transparency Shapes Layer off base of the special eight: the knockout +/// stops at the base's own content (Blend Clipped Layers as Group), so it gives back the base's +/// colour without touching its alpha, and the base's transparency still blends as Fill under a +/// feathered vector mask. Where the fill-0 knocker covers an earlier clipped layer the composite +/// is the base's alone. +#[test] +fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() { + let square = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(&[(2.0, 1.0), (10.0, 1.0), (10.0, 7.0), (2.0, 7.0)])]); + for depth in DEPTHS { + for k in [Knockout::Shallow, Knockout::Deep] { + let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(90.0 / 255.0, 110.0 / 255.0, 130.0 / 255.0)); + let mut base = solid(&d, "base", Rect::new(0, 0, 3, 8), [0.78, 0.59, 0.31, 0.3]); + base.surface_mut().unwrap().fill_rect(Rect::new(3, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), [0.78, 0.59, 0.31, 0.7])); + base.blend = BlendMode::ColorDodge; + base.opacity = 0.53; + base.advanced.transparency_shapes = false; + base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) }); + d.layers.push(base); + let alone = render(&d, d.bounds()).px; + let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]); + red.clipped = true; + let mut knock = knocker(&d, k); + knock.clipped = true; + d.layers.extend([red, knock]); + let got = render(&d, d.bounds()).px; + let what = format!("{k:?} ({depth:?})"); + for (i, (g, a)) in got.iter().zip(&alone).enumerate() { + if i % 12 < 6 { + assert!(close(*g, *a), "{what}: knocked out at {i}: {g:?}, the base alone {a:?}"); + } + } + assert!(!close(got[6 * 12 + 8], alone[6 * 12 + 8]), "{what}: the red shows outside the knocker"); + check(&d, 4, 3, alone[3 * 12 + 4], &what); + } + } +} diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 3e28fccd7e..44b265c8dd 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -105,8 +105,10 @@ fn over_text(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { let dec = |v: f32| 1.0 - photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma)); let mut o = [0.0; 5]; for i in 0..4 { - // Mixing an ink with itself (or with nothing) leaves it exactly as it is. - o[i] = if b[4] <= 0.0 || b[i] == s[i] { s[i] } else { dec((1.0 - sa) * b[4] / a * enc(b[i]) + sa / a * enc(s[i])) }; + // Mixing an ink with itself, with nothing, or over a backdrop it covers fully leaves it + // exactly as it is (the round trip through the gamma space can move it by a rounding + // step, which a mode that jumps at no ink or full ink turns into a whole ink). + o[i] = if b[4] <= 0.0 || sa >= 1.0 || b[i] == s[i] { s[i] } else { dec((1.0 - sa) * b[4] / a * enc(b[i]) + sa / a * enc(s[i])) }; } o[4] = a; o @@ -394,6 +396,64 @@ fn isolated_groups_keep_the_special_fill_rule() { } } +/// White type clipped onto an isolated Vivid Light group over full magenta (#9688): the glyph +/// covers its pixel, so the group shows exactly no ink there, and Vivid Light with no ink (full +/// brightness) gives no ink whatever the backdrop. Mixing the covered pixel through the type +/// gamma would leave a rounding step of ink, which Vivid Light turns into full magenta. +#[test] +fn type_clipped_to_a_vivid_light_group_covers_exactly() { + let mut inner = Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(cmyk_color([0.0, 1.0, 0.0, 0.0], 1.0)))); + inner.blend = BlendMode::Normal; + let mut vivid = Layer::group("vivid", vec![inner]); + vivid.blend = BlendMode::VividLight; + let mut glyph = text_layer(&[(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0])]); + glyph.clipped = true; + let outer = Layer::group("outer", vec![layer(&[(Rect::new(0, 0, 5, 3), [0.0, 1.0, 0.0, 0.0, 1.0])]), vivid, glyph]); + let mut d = doc(6, 4); + d.layers.push(outer); + assert!(supports(&d)); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + assert_eq!(got.px[0], [0.0, 0.0, 0.0, 0.0, 1.0], "white glyph in Vivid Light"); + // Magenta in Vivid Light onto magenta stays magenta. + assert_eq!(got.px[1], [0.0, 1.0, 0.0, 0.0, 1.0], "beside the glyph"); + assert_matches_reference(&d); +} + +/// The same structure in the neighbouring modes, with type or pixels clipped (fully covering and +/// at a partly covered edge), onto inks that sit at the jumps of the modes that have them. +#[test] +fn layers_clipped_to_isolated_groups_match_the_reference_in_every_mode() { + use BlendMode as B; + for mode in [B::VividLight, B::LinearLight, B::PinLight, B::HardMix, B::ColorDodge, B::ColorBurn, B::Normal, B::Multiply, B::Screen] { + for text in [false, true] { + for (backdrop, ink) in [([0.0, 1.0, 0.0, 0.0], [0.0; 4]), ([1.0, 0.0, 1.0, 1.0], [1.0; 4]), ([0.0, 1.0, 0.0, 0.0], [0.0, 0.3, 1.0, 0.0])] { + let mut inner = Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(cmyk_color(backdrop, 1.0)))); + inner.blend = BlendMode::Normal; + let mut group = Layer::group("g", vec![inner]); + group.blend = mode; + let [c, m, y, k] = ink; + let rects = [(Rect::new(0, 0, 2, 2), [c, m, y, k, 1.0]), (Rect::new(2, 0, 3, 2), [c, m, y, k, 0.5])]; + let mut clip = if text { text_layer(&rects) } else { layer(&rects) }; + clip.clipped = true; + let [bc, bm, by, bk] = backdrop; + let outer = Layer::group("outer", vec![layer(&[(Rect::new(0, 0, 4, 2), [bc, bm, by, bk, 1.0])]), group, clip]); + let mut d = doc(4, 3); + d.layers.push(outer); + assert!(supports(&d), "{mode:?}"); + assert_matches_reference(&d); + } + } + } + // Onto a pass-through group, clipped layers take the RGB compositor's isolated rendering. + let mut pass = Layer::group("pass", vec![layer(&[(Rect::new(0, 0, 2, 2), [0.0, 1.0, 0.0, 0.0, 1.0])])]); + pass.blend = BlendMode::PassThrough; + let mut clip = text_layer(&[(Rect::new(0, 0, 1, 1), [0.0; 5])]); + clip.clipped = true; + let mut d = doc(2, 2); + d.layers.extend([pass, clip]); + assert!(!supports(&d)); +} + #[test] fn unsupported_documents_fall_back() { let simple = || { diff --git a/crates/compose/src/composite.rs b/crates/compose/src/composite.rs index dccaed8c4b..bc0de2ffc2 100644 --- a/crates/compose/src/composite.rs +++ b/crates/compose/src/composite.rs @@ -2,7 +2,7 @@ use super::content::{channel_weights, empty_in, recycle_buffer, render_content, use super::ctx::Ctx; use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds}; use super::fx_cache::effect_maps; -use super::*; +use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split}; /// Composite an isolated group's sibling list (bottom→top) onto `backdrop` (its own buffer). pub(crate) fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { @@ -364,6 +364,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: }; } } + // A Frame's stroke over the frame's content, at the group's opacity. + if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) { + blend_into(backdrop, &surface_to_buffer(&stroke, rect), BlendMode::Normal, opacity); + } // Layers clipped to a pass-through group sit atop the group's // isolated rendering; their effect (isolated result with vs. without // them, each placed over the original backdrop) is added to the @@ -443,7 +447,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: *p = psblend::composite(BlendMode::Normal, *p, *s, 1.0); p[3] *= mask_k(&mask, i); } - blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); + blend_layer_into(backdrop, &content, layer, cx, 1.0); return; } if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) @@ -467,14 +471,17 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: } return; } + // The row-at-a-time fast path blends like `blend_into_fill`; Transparency Shapes Layer off + // with one of the special eight needs `blend_layer_into`'s per-pixel mask split. if clipped.is_empty() + && (layer.advanced.transparency_shapes || !psblend::fill_is_special(layer.blend)) && let Some(surf) = pixel_surface(layer) { return composite_pixel_layer(layer, surf, backdrop, cx); } let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); - blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); + blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer)); recycle_buffer(content); } @@ -626,10 +633,34 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { } /// Blend an isolated layer buffer into the backdrop. -fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32) { +pub(crate) fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32) { blend_into_g(backdrop, src, mode, opacity, 1.0); } +/// `layer`'s masked content `src` blended into the backdrop with its own mode, Opacity and Fill +/// ([`blend_into_fill`]). With Transparency Shapes Layer off, the special eight +/// ([`psblend::fill_is_special`]) take the layer's own transparency (its content's alpha, not its +/// masks) inside the blend, as Fill: Photoshop composites a pixel of alpha `a` at Fill `f` as +/// an opaque one at Fill `a × f`, covering the backdrop with Opacity × masks only. Other modes +/// look the same either way (#5692). +fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx, gamma: f32) { + let (mode, opacity, fill) = (layer.blend, layer.opacity, layer.fill_opacity); + if layer.advanced.transparency_shapes || !psblend::fill_is_special(mode) { + return blend_into_fill(backdrop, src, mode, opacity, fill, gamma); + } + let mv = mask_vals(layer, backdrop.rect, cx); + let fill = fill.clamp(0.0, 1.0); + for (i, (b, s)) in backdrop.px.iter_mut().zip(&src.px).enumerate() { + // `src` carries alpha × masks; split them again. + let m = mv.as_ref().map_or(1.0, |v| v.get(i).copied().unwrap_or(1.0)); + if s[3] <= 0.0 || m <= 0.0 { + continue; + } + let alpha = (s[3] / m).clamp(0.0, 1.0); + *b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma); + } +} + /// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for /// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise. pub(crate) fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { diff --git a/crates/compose/src/content.rs b/crates/compose/src/content.rs index 88ec2751a9..ea868d715e 100644 --- a/crates/compose/src/content.rs +++ b/crates/compose/src/content.rs @@ -1,8 +1,8 @@ -use super::composite::{adjustment_quantum, composite_stack}; +use super::composite::{adjustment_quantum, blend_into, composite_stack}; use super::ctx::Ctx; use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside}; use super::fx_cache::effect_maps; -use super::*; +use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split}; /// Tile size for parallel rendering (tile results are independent). pub const RENDER_TILE: i32 = 256; @@ -94,6 +94,10 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option>; diff --git a/crates/compose/src/effects/bevel.rs b/crates/compose/src/effects/bevel.rs index 8b11b94e60..ea07fbd709 100644 --- a/crates/compose/src/effects/bevel.rs +++ b/crates/compose/src/effects/bevel.rs @@ -1,7 +1,7 @@ use super::blur::tent; use super::distance::{INSIDE_EPS, bevel_dists, par_fill, par_units}; use super::paint::{apply_lut, bevel_contour_lut, contour_lut, lut_at, offset, satin_lut}; -use super::*; +use super::{Bevel, BevelStyle, BevelTechnique, GlobalLight, Map, Pattern, Placement, PreparedPatterns, Rect}; /// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the /// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside). diff --git a/crates/compose/src/effects/blur.rs b/crates/compose/src/effects/blur.rs index 686870523d..edea7285d2 100644 --- a/crates/compose/src/effects/blur.rs +++ b/crates/compose/src/effects/blur.rs @@ -1,5 +1,5 @@ use super::distance::{Metric, dist_outside_by}; -use super::*; +use super::{MAX_REACH, Map}; /// The soft falloff of an effect of `size` pixels: two box passes of width `size` (a tent), /// as Photoshop does. Fitted on psd-tools layer_effects.psd against the earlier Gaussian diff --git a/crates/compose/src/effects/composite.rs b/crates/compose/src/effects/composite.rs index cf6402f1ca..36dcc4a2ed 100644 --- a/crates/compose/src/effects/composite.rs +++ b/crates/compose/src/effects/composite.rs @@ -1,7 +1,10 @@ use super::bevel::{BevelPaint, TextureCtx, bevel_geom, bevel_maps, satin_map}; use super::distance::{INSIDE_EPS, Metric, dist_inside_by, dist_outside_by, local_coverage, stroke_outside_exact}; use super::paint::{dissolves_in, glow_map, paint_color, paint_fx, paint_glow, paint_pattern, rgb, shadow_map}; -use super::*; +use super::{ + BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement, PreparedPatterns, Rect, + StrokePosition, psblend, +}; /// Build every effect map for `layer` from its alpha `shape` over `rect`. pub fn build_maps(layer: &Layer, shape: Vec, rect: Rect, light: &GlobalLight, tex: &TextureCtx) -> FxMaps { @@ -176,230 +179,355 @@ pub(crate) fn composite_with_effects_prepared( patterns: &PreparedPatterns<'_>, vstroke: Option>, ) { - let big = content.rect; - let (w, h) = (big.width() as usize, big.height() as usize); - // The effect shape: the content's alpha (with a split-off vector stroke, `content` is the - // unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline. - let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0); - let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a))); - // Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were - // built), and the content's own transparency acts like fill opacity within it. - let shapeless = !layer.advanced.transparency_shapes; - let shape = if shapeless { - maps.crop(&maps.shape, big, 0.0) - } else if maps.outline { - let o = maps.crop(&maps.shape, big, 0.0); - Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() } - } else { - Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() } - }; - let relative = shapeless || maps.outline || vstroke.is_some(); - let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0); - // Layer bounds (gradients aligned with the layer use the whole layer, - // independent of the render rect). - let sb = layer_bounds; - // Linked patterns tile from the effects reference point (else the layer's top-left). - let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))); + let pass = EffectPass::new(layer, maps, content.rect, layer_bounds, patterns, vstroke.as_ref()); + let big = pass.big; + let shape = pass.effect_shape(content); let rect = backdrop.rect; let before = backdrop.clone(); // Exterior effects are painted on a backdrop copy extended to `big`. let mut work = Buffer::transparent(big); for y in rect.y0.max(big.y0)..rect.y1.min(big.y1) { for x in rect.x0.max(big.x0)..rect.x1.min(big.x1) { - work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] = before.get(x, y); - } - } - let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); - let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_)); - // Multiple instances: the first listed is on top, so paint in reverse. - let rev = || items.iter().copied().enumerate().rev(); - - for (i, e) in rev() { - if let Effect::DropShadow(s) = e { - let mut m = fx(i, 0); - if s.knocks_out { - // The layer hides the shadow beneath it where its fill is see-through (at 100 % - // fill in a normal mode it covers it anyway; at 0 % the shape shows the bare - // backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]). - knock_out_exterior(&mut m, &shape, 1.0 - shadow_see_through(layer)); - } - paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); - } - } - for (i, e) in rev() { - if let Effect::OuterGlow(g) = e { - let mut m = fx(i, 0); - // The glow is behind the shape, not painted across its interior when Fill is 0 %. - // Preserve the existing anti-aliased edge when the fill is fully opaque. - knock_out_exterior(&mut m, &shape, layer.fill_opacity); - paint_glow(&mut work, &m, g, sb, anchor, big, patterns); + work.px[((y - big.y0) as usize) * pass.w + (x - big.x0) as usize] = before.get(x, y); } } + pass.paint_exterior(&mut work, &shape); // The layer: content at fill opacity, then interior effects. Interior effects are painted // relative to the layer's shape (alpha = coverage within the shape), then the shape's alpha // applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and // keeps its alpha, as in Photoshop. - let fill = layer.fill_opacity; - // Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are - // combined with the content first, and fill opacity applies to the combination. - let interior_group = layer.advanced.blend_interior && fill < 1.0; - let content_fill = if interior_group { 1.0 } else { fill }; - let inside = |a: f32| a > INSIDE_EPS; - // Within an outline (or a split-off vector stroke) the content's own transparency (a fading - // gradient fill) acts like fill opacity: the effects still cover the whole shape. - let lay_alpha = |i: usize, p: &[f32; 4], a: f32| { - if !inside(a) { - 0.0 - } else if relative { - content_fill * (kmask(i) * p[3] / a).min(1.0) - } else { - content_fill - } - }; - let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }; let gamma = crate::text_gamma(layer); let premerge = premerges_layer(layer); + let mut lay = pass.layer_pixels(content, &shape); if premerge { for (lp, wp) in lay.px.iter_mut().zip(&work.px) { *lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma); } } - let rel = |m: Map| -> Map { - let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect(); - Map { w: m.w, h: m.h, v } - }; - let full = Map { w, h, v: shape.v.iter().map(|a| if inside(*a) { 1.0 } else { 0.0 }).collect() }; - for (_, e) in rev() { - if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e { - paint_pattern(&mut lay, &full, patterns, name, id, Placement::anchored(anchor, *link, *phase, *scale, *angle), big, common.blend, common.opacity); - } - } - for (_, e) in rev() { - if let Effect::GradientOverlay { common, gradient, .. } = e { - paint_fx(&mut lay, &full, &FxPaint::Gradient(gradient.clone()), sb, anchor, big, common.blend, common.opacity, patterns) - } - } - for (_, e) in rev() { - if let Effect::ColorOverlay { common, color } = e { - paint_color(&mut lay, &full, rgb(color), common.blend, common.opacity); - } - } - for (i, e) in rev() { - if let Effect::Satin(s) = e { - let m = rel(fx(i, 0)); - paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); - } - } - for (i, e) in rev() { - if let Effect::InnerGlow(g) = e { - paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns); - } - } - if interior_group { - for p in &mut lay.px { - p[3] *= fill.max(0.0); - } - } - for (i, e) in rev() { - if let Effect::InnerShadow(s) = e { - let m = rel(fx(i, 0)); - paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); - } - } - // A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and - // interior effects, below the stroke effect (psd-tools stroke-composite). - if let Some(vs) = &vstroke { - for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() { - if s[3] > 0.0 && inside(*a) { - *p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0); - } - } - } - // Strokes. Inside parts are painted over the layer (bottom instance first); outside parts - // are slid beneath it, so the first listed (top) instance is processed first. - let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); - let ready = !strokes.is_empty(); - let din = if ready { maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default() } else { Vec::new() }; - // A stroke's outer edge picks the chamfer or the exact field by its width - // ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist. - let crop_out = |v: &Option>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR)); - let (dout, dout_exact, vdout, vdout_exact) = - if ready { (crop_out(&maps.dout), crop_out(&maps.dout_exact), crop_out(&maps.vdout), crop_out(&maps.vdout_exact)) } else { (None, None, None, None) }; - let widths = stroke_widths; - // A gradient stroke aligned with the layer spans the stroke's own extent. - let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb); - for st in strokes.iter().rev().copied() { - let (in_w, _) = widths(st); - if in_w <= 0.0 { - continue; - } - let mut m = Map::new(w, h, 0.0); - for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) { - *mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 }; - } - paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns); - } - for (i, e) in rev() { - if let Effect::BevelEmboss(b) = e - && maps.bevel_paint[i] == BevelPaint::Inner - { - let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1))); - paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); - paint_color(&mut lay, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); - } - } + let strokes: Vec<&photocraft_doc::StrokeFx> = pass.items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); + let fields = pass.stroke_fields(!strokes.is_empty()); + pass.paint_interior(&mut lay, &shape); + pass.paint_vector_stroke(&mut lay, &shape); + pass.paint_inside_strokes(&mut lay, &shape, &strokes, &fields); + pass.paint_bevel(&mut lay, BevelPaint::Inner, Some(&shape)); // The shape's own alpha. for (p, a) in lay.px.iter_mut().zip(&shape.v) { p[3] *= a.min(1.0); } - // Shape layers without a filled outline stroke their vector outline, estimated from local - // coverage; every other layer strokes its shape. The width then picks the field. - let vector_outline = vector_shape && !maps.outline; - // Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A - // higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider - // Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its - // coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking - // them top over bottom. - if strokes.iter().any(|st| widths(st).1 > 0.0) { + pass.paint_outside_strokes(&mut work, &lay, &shape, &strokes, &fields); + pass.paint_bevel(&mut work, BevelPaint::Outer, None); + + // Layer (with interior effects) onto the exterior result, in the layer's mode. + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + for (wp, lp) in work.px.iter_mut().zip(&lay.px) { + if lp[3] > 0.0 { + *wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma); + } + } + // Emboss styles shade the composited layer (their maps cover inside and outside). Painting + // them into the layer before its (text-gamma) composite left a type layer's lit top edges up + // to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255). + pass.paint_bevel(&mut work, BevelPaint::Both, None); + pass.write_back(backdrop, &before, &work); +} + +/// `true` where the effect shape covers a pixel enough for interior effects to paint. +fn inside(a: f32) -> bool { + a > INSIDE_EPS +} + +/// An effect map as a fraction of the shape's own coverage. +fn relative_to_shape(m: Map, shape: &Map) -> Map { + let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect(); + Map { w: m.w, h: m.h, v } +} + +/// A stroke's distance fields, cropped to the render region; only the ones a stroke needs exist. +#[derive(Default)] +struct StrokeFields { + /// Distance inside the shape. + din: Vec, + dout: Option>, + dout_exact: Option>, + vdout: Option>, + vdout_exact: Option>, +} + +/// What compositing one layer's effects shares between its stages. +struct EffectPass<'a> { + layer: &'a Layer, + maps: &'a FxMaps, + patterns: &'a PreparedPatterns<'a>, + vstroke: Option<&'a VectorStroke<'a>>, + /// The region effects are painted over: the content's rect. + big: Rect, + w: usize, + h: usize, + /// Layer bounds (gradients aligned with the layer use the whole layer, independent of the + /// render rect). + sb: Rect, + /// Linked patterns tile from the effects reference point (else the layer's top-left). + anchor: (f64, f64), + /// The enabled effects. Multiple instances: the first listed is on top, so paint in reverse. + items: Vec<&'a Effect>, +} + +impl<'a> EffectPass<'a> { + fn new( + layer: &'a Layer, + maps: &'a FxMaps, + big: Rect, + layer_bounds: Rect, + patterns: &'a PreparedPatterns<'a>, + vstroke: Option<&'a VectorStroke<'a>>, + ) -> Self { + let sb = layer_bounds; + Self { + layer, + maps, + patterns, + vstroke, + big, + w: big.width() as usize, + h: big.height() as usize, + sb, + anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))), + items: layer.effects.items.iter().filter(|e| e.enabled()).collect(), + } + } + + /// The enabled effects with their indexes, last listed first. + fn rev(&self) -> impl Iterator + '_ { + self.items.iter().copied().enumerate().rev() + } + + /// Effect `i`'s map `k`, cropped to the region. + fn fx(&self, i: usize, k: usize) -> Map { + self.maps.crop(&self.maps.per[i][k], self.big, 0.0) + } + + /// The vector stroke's layer-mask value at pixel `i` (1 with no mask). + fn kmask(&self, i: usize) -> f32 { + self.vstroke.and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0) + } + + /// Alpha `a` of pixel `i` joined with the vector stroke's. + fn union(&self, i: usize, a: f32) -> f32 { + self.vstroke.and_then(|v| v.stroke.px.get(i)).map_or(a, |s| self.kmask(i) * (a + s[3] * (1.0 - a))) + } + + /// Within an outline (or a split-off vector stroke), or without Transparency Shapes Layer, + /// the content's own transparency acts like fill opacity: the effects still cover the whole + /// shape. + fn relative(&self) -> bool { + !self.layer.advanced.transparency_shapes || self.maps.outline || self.vstroke.is_some() + } + + /// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the + /// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline. + fn effect_shape(&self, content: &Buffer) -> Map { + let (w, h, big) = (self.w, self.h, self.big); + // Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were + // built), and the content's own transparency acts like fill opacity within it. + if !self.layer.advanced.transparency_shapes { + self.maps.crop(&self.maps.shape, big, 0.0) + } else if self.maps.outline { + let o = self.maps.crop(&self.maps.shape, big, 0.0); + Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(self.union(i, p[3]))).collect() } + } else { + Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| self.union(i, p[3])).collect() } + } + } + + /// Paints the effects behind the layer: drop shadows, then outer glows. + fn paint_exterior(&self, work: &mut Buffer, shape: &Map) { + for (i, e) in self.rev() { + if let Effect::DropShadow(s) = e { + let mut m = self.fx(i, 0); + if s.knocks_out { + // The layer hides the shadow beneath it where its fill is see-through (at 100 % + // fill in a normal mode it covers it anyway; at 0 % the shape shows the bare + // backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]). + knock_out_exterior(&mut m, shape, 1.0 - shadow_see_through(self.layer)); + } + paint_color(work, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + for (i, e) in self.rev() { + if let Effect::OuterGlow(g) = e { + let mut m = self.fx(i, 0); + // The glow is behind the shape, not painted across its interior when Fill is 0 %. + // Preserve the existing anti-aliased edge when the fill is fully opaque. + knock_out_exterior(&mut m, shape, self.layer.fill_opacity); + paint_glow(work, &m, g, self.sb, self.anchor, self.big, self.patterns); + } + } + } + + /// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are + /// combined with the content first, and fill opacity applies to the combination. + fn interior_group(&self) -> bool { + self.layer.advanced.blend_interior && self.layer.fill_opacity < 1.0 + } + + /// The layer's own pixels at fill opacity, with their alpha made relative to `shape`. + fn layer_pixels(&self, content: &Buffer, shape: &Map) -> Buffer { + let content_fill = if self.interior_group() { 1.0 } else { self.layer.fill_opacity }; + let relative = self.relative(); + let lay_alpha = |i: usize, p: &[f32; 4], a: f32| { + if !inside(a) { + 0.0 + } else if relative { + content_fill * (self.kmask(i) * p[3] / a).min(1.0) + } else { + content_fill + } + }; + Buffer { rect: self.big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() } + } + + /// Paints the interior effects other than strokes and bevels: overlays, satin, inner glow + /// and inner shadow. + fn paint_interior(&self, lay: &mut Buffer, shape: &Map) { + let (w, h, big, sb, anchor, patterns) = (self.w, self.h, self.big, self.sb, self.anchor, self.patterns); + let full = Map { w, h, v: shape.v.iter().map(|a| if inside(*a) { 1.0 } else { 0.0 }).collect() }; + for (_, e) in self.rev() { + if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e { + paint_pattern(lay, &full, patterns, name, id, Placement::anchored(anchor, *link, *phase, *scale, *angle), big, common.blend, common.opacity); + } + } + for (_, e) in self.rev() { + if let Effect::GradientOverlay { common, gradient, .. } = e { + paint_fx(lay, &full, &FxPaint::Gradient(gradient.clone()), sb, anchor, big, common.blend, common.opacity, patterns) + } + } + for (_, e) in self.rev() { + if let Effect::ColorOverlay { common, color } = e { + paint_color(lay, &full, rgb(color), common.blend, common.opacity); + } + } + for (i, e) in self.rev() { + if let Effect::Satin(s) = e { + let m = relative_to_shape(self.fx(i, 0), shape); + paint_color(lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + for (i, e) in self.rev() { + if let Effect::InnerGlow(g) = e { + paint_glow(lay, &relative_to_shape(self.fx(i, 0), shape), g, sb, anchor, big, patterns); + } + } + if self.interior_group() { + for p in &mut lay.px { + p[3] *= self.layer.fill_opacity.max(0.0); + } + } + for (i, e) in self.rev() { + if let Effect::InnerShadow(s) = e { + let m = relative_to_shape(self.fx(i, 0), shape); + paint_color(lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + } + + /// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and + /// interior effects, below the stroke effect (psd-tools stroke-composite). + fn paint_vector_stroke(&self, lay: &mut Buffer, shape: &Map) { + let Some(vs) = self.vstroke else { + return; + }; + let fill = self.layer.fill_opacity; + for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() { + if s[3] > 0.0 && inside(*a) { + *p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (self.kmask(i) * s[3] / a).min(1.0)], 1.0); + } + } + } + + /// The distance fields the strokes need, cropped to the region; empty unless `ready` (the + /// layer has a stroke). + /// + /// A stroke's outer edge picks the chamfer or the exact field by its width + /// ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist. + fn stroke_fields(&self, ready: bool) -> StrokeFields { + if !ready { + return StrokeFields::default(); + } + let (maps, big) = (self.maps, self.big); + let crop_out = |v: &Option>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR)); + StrokeFields { + din: maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default(), + dout: crop_out(&maps.dout), + dout_exact: crop_out(&maps.dout_exact), + vdout: crop_out(&maps.vdout), + vdout_exact: crop_out(&maps.vdout_exact), + } + } + + /// A gradient stroke aligned with the layer spans the stroke's own extent. + fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Rect { + self.maps.stroke_frame(st).unwrap_or(self.sb) + } + + /// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts + /// are slid beneath it, so the first listed (top) instance is processed first. + fn paint_inside_strokes(&self, lay: &mut Buffer, shape: &Map, strokes: &[&photocraft_doc::StrokeFx], fields: &StrokeFields) { + for st in strokes.iter().rev().copied() { + let (in_w, _) = stroke_widths(st); + if in_w <= 0.0 { + continue; + } + let mut m = Map::new(self.w, self.h, 0.0); + for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&fields.din) { + *mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 }; + } + paint_fx(lay, &m, &st.paint, self.stroke_frame(st), self.anchor, self.big, st.common.blend, st.common.opacity, self.patterns); + } + } + + /// Bevel and emboss effects whose paint is `kind`. Inner ones paint into the layer relative + /// to its `shape`; the others paint the maps as they are. + fn paint_bevel(&self, target: &mut Buffer, kind: BevelPaint, shape: Option<&Map>) { + for (i, e) in self.rev() { + if let Effect::BevelEmboss(b) = e + && self.maps.bevel_paint[i] == kind + { + let map = |k: usize| match shape { + Some(shape) => relative_to_shape(self.fx(i, k), shape), + None => self.fx(i, k), + }; + paint_color(target, &map(0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); + paint_color(target, &map(1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); + } + } + } + + /// The outside parts of the strokes, blended onto the backdrop beneath the layer with their + /// own mode. A higher stroke covers the ones below it (Photoshop: a Multiply stroke listed + /// above a wider Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's + /// share is its coverage × opacity not yet taken by strokes above; for Normal strokes this + /// equals stacking them top over bottom. + fn paint_outside_strokes(&self, work: &mut Buffer, lay: &Buffer, shape: &Map, strokes: &[&photocraft_doc::StrokeFx], fields: &StrokeFields) { + if !strokes.iter().any(|st| stroke_widths(st).1 > 0.0) { + return; + } + let (w, h) = (self.w, self.h); let base = work.clone(); let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended let mut cover = vec![0.0f32; w * h]; for st in strokes.iter().copied() { - let (_, out_w) = widths(st); + let (_, out_w) = stroke_widths(st); if out_w <= 0.0 { continue; } - // Shape layers: Photoshop strokes the vector outline (estimated from local coverage - // for unfilled shapes); the stroke never shows through the shape's pixels. Along a - // filled shape's outline it covers the part of each edge pixel outside the path. - let exact = stroke_outside_exact(out_w); - let d: &[f32] = if vector_outline { - (if exact { vdout_exact.as_deref() } else { vdout.as_deref() }).unwrap_or_default() - } else if exact { - dout_exact.as_deref().unwrap_or_default() - } else { - dout.as_deref().unwrap_or_default() - }; - let mut share = vec![0.0f32; w * h]; - for (i, sh) in share.iter_mut().enumerate() { - let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0); - let k = - if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band }; - let mut k = k * st.common.opacity; - if st.common.blend == BlendMode::Dissolve { - let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64); - k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 }; - } - *sh = k * (1.0 - cover[i]); - cover[i] += *sh; - } + let share = self.outside_share(st, out_w, shape, lay, fields, &mut cover); // The stroke blended over the backdrop at full coverage. let mut blended = base.clone(); let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() }; // The share already carries the Dissolve choice, so the paint itself composites as Normal. let paint_blend = if st.common.blend == BlendMode::Dissolve { BlendMode::Normal } else { st.common.blend }; - paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, paint_blend, 1.0, patterns); + paint_fx(&mut blended, &ones, &st.paint, self.stroke_frame(st), self.anchor, self.big, paint_blend, 1.0, self.patterns); for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) { if *s > 0.0 { for c in 0..3 { @@ -420,48 +548,59 @@ pub(crate) fn composite_with_effects_prepared( } } } - for (i, e) in rev() { - if let Effect::BevelEmboss(b) = e - && maps.bevel_paint[i] == BevelPaint::Outer - { - let (hi, sh) = (fx(i, 0), fx(i, 1)); - paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); - paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); + + /// The share of each pixel an outside stroke `st` (outside width `out_w`) takes: its coverage + /// × opacity not yet taken by the strokes above, which `cover` accumulates. + fn outside_share(&self, st: &photocraft_doc::StrokeFx, out_w: f32, shape: &Map, lay: &Buffer, fields: &StrokeFields, cover: &mut [f32]) -> Vec { + let (w, big) = (self.w, self.big); + // Shape layers without a filled outline stroke their vector outline, estimated from local + // coverage; every other layer strokes its shape. The width then picks the field. + let vector_shape = matches!(self.layer.content, photocraft_doc::LayerContent::Shape(_)); + let vector_outline = vector_shape && !self.maps.outline; + // Shape layers: Photoshop strokes the vector outline (estimated from local coverage + // for unfilled shapes); the stroke never shows through the shape's pixels. Along a + // filled shape's outline it covers the part of each edge pixel outside the path. + let exact = stroke_outside_exact(out_w); + let d: &[f32] = match (vector_outline, exact) { + (true, true) => fields.vdout_exact.as_deref(), + (true, false) => fields.vdout.as_deref(), + (false, true) => fields.dout_exact.as_deref(), + (false, false) => fields.dout.as_deref(), } + .unwrap_or_default(); + let mut share = vec![0.0f32; w * self.h]; + for (i, sh) in share.iter_mut().enumerate() { + let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0); + let k = if inside(shape.v[i]) { if vector_outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band }; + let mut k = k * st.common.opacity; + if st.common.blend == BlendMode::Dissolve { + let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64); + k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 }; + } + *sh = k * (1.0 - cover[i]); + cover[i] += *sh; + } + share } - // Layer (with interior effects) onto the exterior result, in the layer's mode. - let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; - for (wp, lp) in work.px.iter_mut().zip(&lay.px) { - if lp[3] > 0.0 { - *wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma); - } - } - // Emboss styles shade the composited layer (their maps cover inside and outside). Painting - // them into the layer before its (text-gamma) composite left a type layer's lit top edges up - // to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255). - for (i, e) in rev() { - if let Effect::BevelEmboss(b) = e - && maps.bevel_paint[i] == BevelPaint::Both - { - paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); - paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); - } - } - // Layer opacity applies to the whole stack. - let op = layer.opacity; - for y in rect.y0..rect.y1 { - for x in rect.x0..rect.x1 { - let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; - let a = before.px[i]; - let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a }; - backdrop.px[i] = if layer.blend == BlendMode::Dissolve { - // Dissolve applies layer opacity to the completed interior/exterior effect - // stack as pixel coverage, not continuous gray interpolation (#3051). - if crate::dissolve_noise(x, y) < op { b } else { a } - } else { - mix_premul(a, b, op) - }; + /// Writes the finished stack `work` into `backdrop` (over `before`) at layer opacity. + fn write_back(&self, backdrop: &mut Buffer, before: &Buffer, work: &Buffer) { + let (big, w) = (self.big, self.w); + let rect = backdrop.rect; + let op = self.layer.opacity; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + let a = before.px[i]; + let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a }; + backdrop.px[i] = if self.layer.blend == BlendMode::Dissolve { + // Dissolve applies layer opacity to the completed interior/exterior effect + // stack as pixel coverage, not continuous gray interpolation (#3051). + if crate::dissolve_noise(x, y) < op { b } else { a } + } else { + mix_premul(a, b, op) + }; + } } } } diff --git a/crates/compose/src/effects/distance.rs b/crates/compose/src/effects/distance.rs index 8581e1c821..ed92d9f973 100644 --- a/crates/compose/src/effects/distance.rs +++ b/crates/compose/src/effects/distance.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Map; /// 1D squared distance transform (Felzenszwalb & Huttenlocher). fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32], near: &mut [usize]) { diff --git a/crates/compose/src/effects/paint.rs b/crates/compose/src/effects/paint.rs index a24e819752..99b0c6aa0b 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -1,6 +1,9 @@ use super::blur::{blur, dilate}; use super::distance::{dist_inside, dist_outside}; -use super::*; +use super::{ + BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect, + Shadow, psblend, +}; pub(super) fn contour_lut(c: &Contour) -> Option> { match c { diff --git a/crates/compose/src/extents.rs b/crates/compose/src/extents.rs index 77b99bed85..d895f14bc9 100644 --- a/crates/compose/src/extents.rs +++ b/crates/compose/src/extents.rs @@ -1,6 +1,6 @@ use super::content::{render_content, surface_to_buffer}; use super::ctx::Ctx; -use super::*; +use super::{BlendMode, Document, Layer, LayerContent, Rect, advanced, bounds, effects, masks, shape_split}; /// Bounds of a layer's own pixels (union over group children; the canvas /// for fill layers; an artboard's board). @@ -11,7 +11,7 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { } match &layer.content { LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect), - LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { + LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(frame_stroke_bounds(layer, canvas), |acc, c| { let b = layer_bounds(c, canvas); if b.is_empty() { acc @@ -47,23 +47,36 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { } } +/// Where a Photoshop Frame's stroke ([`shape_split::frame_shape`]) draws, within `canvas`; empty +/// for other layers. Taken from the compiled stroke the renderer draws, not the path grown by the +/// stroke width: a mitered corner reaches up to `miter_limit` widths past the path, and a frame +/// has no pixels of its own that would cover it. +fn frame_stroke_bounds(layer: &Layer, canvas: Rect) -> Rect { + shape_split::frame_shape(layer) + .and_then(|sh| photocraft_vector::CompiledShape::new(&sh, photocraft_vector::DEFAULT_TOLERANCE, canvas).bounds()) + .map_or(Rect::EMPTY, |r| r.intersect(&canvas)) +} + /// Where a layer's effects take their shape from: [`layer_bounds`], except that a group's /// children count with their own effects (a child's outside stroke shapes the group's drop /// shadow and must not be cut off at the child's pixels, #3274). pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { match &layer.content { - LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { - let m = if effects::has_effects(c) { effects::margin(c) } else { 0 }; - let b = effect_source_bounds(c, canvas); - let b = if b.is_empty() { b } else { b.inflate(m) }; - if b.is_empty() { - acc - } else if acc.is_empty() { - b - } else { - acc.union(&b) - } - }), + // A Frame's stroke is part of the group's content its effects are built from. + LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => { + g.children.iter().filter(|c| c.visible).fold(frame_stroke_bounds(layer, canvas), |acc, c| { + let m = if effects::has_effects(c) { effects::margin(c) } else { 0 }; + let b = effect_source_bounds(c, canvas); + let b = if b.is_empty() { b } else { b.inflate(m) }; + if b.is_empty() { + acc + } else if acc.is_empty() { + b + } else { + acc.union(&b) + } + }) + } _ => layer_bounds(layer, canvas), } } @@ -343,7 +356,7 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option { } let own = match &layer.content { LayerContent::Group(g) if g.artboard.is_none() => { - let mut acc = Rect::EMPTY; + let mut acc = frame_stroke_bounds(layer, canvas); for c in g.children.iter().filter(|c| c.visible) { let b = composite_bounds(c, canvas)?; if !b.is_empty() { diff --git a/crates/compose/src/fx_cache.rs b/crates/compose/src/fx_cache.rs index cd08eaff7c..43e524d37f 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -1,7 +1,7 @@ use super::composite::texture_ctx; use super::ctx::{Ctx, PREPARE_DEPTH}; use super::extents::{effect_shape, effect_source_bounds}; -use super::*; +use super::{Layer, LayerContent, Rect, Surface, advanced, effects}; // --------------------------------------------------------------------------------------------- // Layer-effect map cache @@ -44,7 +44,7 @@ pub(crate) fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map:: format!("{:?}", c.blend).hash(h); layer_identity(c, h, pin); } - format!("{:?}", g.artboard).hash(h); + format!("{:?}{:?}", g.artboard, g.frame_stroke).hash(h); } LayerContent::Fill(f) => { format!("{f:?}").hash(h); diff --git a/crates/compose/src/reduced.rs b/crates/compose/src/reduced.rs index 479bf1655f..1249df6437 100644 --- a/crates/compose/src/reduced.rs +++ b/crates/compose/src/reduced.rs @@ -1,7 +1,7 @@ use super::content::RENDER_TILE; use super::ctx::Ctx; use super::render::{band_rows_for, render, render_bands, render_tiled_with}; -use super::*; +use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform}; /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 33b17d25b8..b739d3f42a 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -1,7 +1,7 @@ use super::composite::composite_stack_at; use super::content::RENDER_TILE; use super::ctx::{Ctx, RenderVectorMasks, prepare_effects, prepare_tones}; -use super::*; +use super::{Buffer, Document, Layer, Rect, Surface, adjust, advanced, cmyk, multichannel, native, native_lab, pattern, psblend}; /// Every layer the compositor draws, groups before their children: hidden layers and everything /// inside hidden groups are left out. diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index b2fd28116e..c6e76fb0b2 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -8,6 +8,7 @@ use std::sync::{Mutex, OnceLock}; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::vector::ShapeLayer; +use photocraft_doc::{Layer, LayerContent}; use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::Surface; @@ -97,6 +98,22 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac Some(parts) } +/// The stroke-only shape a Photoshop Frame's stroke draws: `Group::frame_stroke` along the +/// group's enabled vector mask (the frame outline). `None` for other layers and for a frame +/// without a visible stroke or a vector mask. +pub fn frame_shape(layer: &Layer) -> Option { + let LayerContent::Group(g) = &layer.content else { return None }; + let stroke = g.frame_stroke.as_ref().filter(|s| s.width.is_finite() && s.width > 0.0 && s.opacity > 0.0)?; + let vm = layer.vector_mask.as_ref().filter(|m| m.enabled && !m.path.is_empty())?; + Some(ShapeLayer { path: vm.path.clone(), fill: None, stroke: Some(stroke.clone()), ..ShapeLayer::default() }) +} + +/// A Photoshop Frame's stroke ([`frame_shape`]) rendered over `canvas` in RGBA at `depth`, from +/// the same cache as the shape strokes. It goes above the frame's (masked) content. +pub fn frame_stroke(layer: &Layer, canvas: Rect, depth: SampleType) -> Option { + split(&frame_shape(layer)?, canvas, depth).map(|(_, stroke)| stroke) +} + /// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke /// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we /// drew no stroke, and elsewhere in the colour that, drawn over the fill, gives back the pixels' diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 76ba8dc72b..7f939c84a5 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1358,6 +1358,46 @@ fn group_effects_reach_past_children_effects() { assert!(close4(px(&d, 24, 43), [1.0; 4]), "nothing past the shadow: {:?}", px(&d, 24, 43)); } +/// An empty Photoshop Frame with an outside stroke along `points` (#4381). +fn stroked_frame(points: &[(f64, f64)], width: f32) -> Layer { + use photocraft_doc::vector::{Path, ShapeStroke, StrokeAlign, Subpath, VectorMask}; + let mut l = Layer::group("Frame 1", vec![]); + l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(points)]))); + if let LayerContent::Group(g) = &mut l.content { + g.frame_stroke = Some(ShapeStroke { width, align: StrokeAlign::Outside, paint: Fill::Solid(Color::rgb(0.0, 0.0, 1.0)), ..Default::default() }); + } + l +} + +#[test] +fn frame_stroke_bounds_hold_its_mitered_corners() { + // A flat triangle's sharp corners miter far past the path (Photoshop's miter limit is 100): + // more than the stroke width, which is all the path's bounds grow by. + let canvas = Rect::new(0, 0, 64, 64); + let frame = stroked_frame(&[(20.0, 32.0), (44.0, 32.0), (32.0, 29.0)], 2.0); + let drawn = crate::bounds::content_bounds(&shape_split::frame_stroke(&frame, canvas, SampleType::U8).unwrap()); + assert!(drawn.x0 < 20 - 2 && drawn.x1 > 44 + 2, "the miters reach past the width: {drawn:?}"); + for b in [layer_bounds(&frame, canvas), composite_bounds(&frame, canvas).unwrap(), effect_source_bounds(&frame, canvas)] { + assert_eq!(b.union(&drawn), b, "{b:?} holds the stroke {drawn:?}"); + } +} + +#[test] +fn an_empty_frames_effects_follow_its_stroke() { + // No children: the frame's drop shadow is shaped by its stroke alone. + let mut d = doc_white(48, 48); + let mut frame = stroked_frame(&[(14.0, 14.0), (34.0, 14.0), (34.0, 34.0), (14.0, 34.0)], 4.0); + frame.effects.items = vec![Effect::DropShadow(shadow(3.0, 90.0))]; + d.layers.push(frame); + let canvas = d.bounds(); + let drawn = crate::bounds::content_bounds(&shape_split::frame_stroke(&d.layers[1], canvas, SampleType::U8).unwrap()); + let b = effect_source_bounds(&d.layers[1], canvas); + assert!(!drawn.is_empty() && b.union(&drawn) == b, "{b:?} holds the stroke {drawn:?}"); + assert!(close4(px(&d, 24, 36), [0.0, 0.0, 1.0, 1.0]), "stroke below the frame: {:?}", px(&d, 24, 36)); + assert!(close4(px(&d, 24, 39), [0.0, 0.0, 0.0, 1.0]), "shadow below the stroke: {:?}", px(&d, 24, 39)); + assert!(close4(px(&d, 24, 24), [1.0; 4]), "the frame's inside is empty: {:?}", px(&d, 24, 24)); +} + #[test] fn drop_shadow_uses_global_light_and_knockout() { let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]); @@ -3291,23 +3331,28 @@ fn effect_maps_with_nested_rayon_work_do_not_deadlock() { // #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on // it, its worker stole another tile of the same layer, which then blocked on the init held // further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is - // purged every round so the maps are rebuilt. A watchdog turns a hang into a failure. + // purged every round so the maps are rebuilt. A watchdog turns a hang into a failure: it + // fails when 120 s pass without a render finishing, however long all 101 renders take. + const ROUNDS: usize = 100; let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return }; let d = nested_parallel_fx_doc(); let first = pool.install(|| render_tiled(&d, d.bounds(), 64)); - for _ in 0..100 { + let _ = tx.send(()); + for _ in 0..ROUNDS { purge_effect_cache(); let out = pool.install(|| render_tiled(&d, d.bounds(), 64)); assert!(out.px == first.px, "renders differ between rounds"); + let _ = tx.send(()); } - let _ = tx.send(()); }); - match rx.recv_timeout(std::time::Duration::from_secs(120)) { - Ok(()) => {} - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"), + for _ in 0..=ROUNDS { + match rx.recv_timeout(std::time::Duration::from_secs(120)) { + Ok(()) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"), + } } } diff --git a/crates/doc/src/adjust.rs b/crates/doc/src/adjust.rs index e197236a4a..056235ed42 100644 --- a/crates/doc/src/adjust.rs +++ b/crates/doc/src/adjust.rs @@ -17,6 +17,24 @@ pub fn is_identity_curve(points: &[CurvePoint]) -> bool { } } +/// A 3D LUT file format (Photoshop's `LUTFormat`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LutFormat { + #[serde(rename = "cube")] + Cube, + #[serde(rename = "3dl")] + ThreeDl, + #[serde(rename = "look")] + Look, +} + +/// A Color Lookup's LUT file as loaded: its format and bytes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LutSource { + pub format: LutFormat, + pub bytes: Arc>, +} + /// Entries in a pencil-drawn Curves map (one per 8-bit level, as in PSD `curv` maps). pub const CURVE_MAP_LEN: usize = 256; @@ -260,6 +278,11 @@ pub enum Adjustment { /// Ordered dither of ±½ an 8-bit step to hide banding (Photoshop's "Dither"). #[serde(default)] dither: bool, + /// The LUT file `lut` was read from, embedded unchanged on PSD save as Photoshop does. + /// None for a table made in PhotoCraft (a built-in look); a save also ignores it when + /// it no longer parses to `lut`. + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, }, Invert, Posterize { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6ec9bc67e3..33589e731e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -318,6 +318,10 @@ pub struct Group { pub expanded: bool, /// Set when this (top-level) group is an artboard: children are clipped to its rect. pub artboard: Option, + /// A Photoshop Frame's stroke (Properties › Stroke; PSD `vstk` on the frame group): drawn + /// along the group's enabled vector mask (the frame outline), above the framed content and + /// not clipped by the frame. Ignored without a vector mask. + pub frame_stroke: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -703,7 +707,7 @@ impl Layer { Self::new(name, LayerContent::Raster(Surface::new(format))) } pub fn group(name: impl Into, children: Vec) -> Self { - let mut l = Self::new(name, LayerContent::Group(Group { children, expanded: true, artboard: None })); + let mut l = Self::new(name, LayerContent::Group(Group { children, expanded: true, artboard: None, frame_stroke: None })); l.blend = BlendMode::PassThrough; l } @@ -1509,7 +1513,7 @@ mod tests { assert_eq!(LayerContent::Adjustment(Adjustment::Invert).article(), "an"); assert_eq!(LayerContent::Adjustment(Adjustment::Invert).kind_name(), "Adjustment"); assert_eq!(LayerContent::Raster(Surface::new(PixelFormat::RGBA8)).article(), "a"); - assert_eq!(LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None }).article(), "a"); + assert_eq!(LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }).article(), "a"); } } diff --git a/crates/engine/src/adjust_params/tests.rs b/crates/engine/src/adjust_params/tests.rs index a7b6cc53de..47a06738f8 100644 --- a/crates/engine/src/adjust_params/tests.rs +++ b/crates/engine/src/adjust_params/tests.rs @@ -63,6 +63,11 @@ fn every_kind_round_trips_through_params() { #[test] fn params_reach_the_model() { + basic_params_reach_the_model(); + color_space_params_reach_the_model(); +} + +fn basic_params_reach_the_model() { let a = from_params("colorBalance", &json!({"midtones": [50, 0, -200]}), None, ColorMode::Rgb).unwrap(); assert_eq!(a, Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [50.0, 0.0, -100.0], highlights: [0.0; 3], preserve_luminosity: true }); let a = from_params("channelMixer", &json!({"monochrome": true}), None, ColorMode::Rgb).unwrap(); @@ -82,6 +87,9 @@ fn params_reach_the_model() { assert!(matches!(a, Adjustment::HueSaturation { colorize: true, saturation: 0.0, hue: 120.0, .. }), "{a:?}"); let a = from_params("gradientMap", &json!({"stops": [[1, "#ffffff"], [0, "#000000"]], "dither": true}), None, ColorMode::Rgb).unwrap(); assert!(matches!(&a, Adjustment::GradientMap { stops, dither: true, .. } if stops[0].0 == 0.0)); +} + +fn color_space_params_reach_the_model() { // CMYK curves: ink channels; Lab: lightness without a composite. let a = from_params("curves", &json!({"black": [[0, 0], [255, 200]]}), None, ColorMode::Rgb).unwrap(); assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Cmyk, black, .. } if black.len() == 2)); diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index 47677e21ff..7823fdb18a 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -36,17 +36,18 @@ pub(crate) fn copy(s: &mut Session) -> Result> { let Some(d) = s.active() else { return Ok(None) }; let doc = &d.doc; let canvas = doc.bounds(); - // The surface the values come from, and which of its samples is the channel. - let (src, k) = match d.channel_view.target { + // The surface the values come from, which of its samples is the channel, and whether it stores + // ink (CMYK, a spot channel) rather than tone. + let (src, k, ink) = match d.channel_view.target { ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => return Ok(None), ChannelTarget::Color(k) => { let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?; - (surf, k) + (surf, k, surf.format().mode == ColorMode::Cmyk) } ChannelTarget::Alpha(i) => { let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?; - (&c.surface, 0) + (&c.surface, 0, c.spot.is_some()) } }; let n = src.format().channels(); @@ -64,7 +65,8 @@ pub(crate) fn copy(s: &mut Session) -> Result> { let len = (values.len() / n).checked_mul(2).ok_or_else(|| EngineError::Other("the channel is too large to copy".into()))?; let mut px = crate::allocation::capacity(len, "copying a channel")?; for (i, p) in values.chunks_exact(n).enumerate() { - px.push(p.get(k).copied().unwrap_or(0.0)); + // A CMYK or spot channel copies as its tone (#5787). + px.push(p.get(k).map_or(0.0, |&v| if ink { 1.0 - v } else { v })); px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0))); } let mut surface = Surface::new(fmt); @@ -91,7 +93,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: let area = src.content_bounds().intersect(&canvas); s.edit(label, |doc, _| { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; - let n = surf.format().channels(); + let (n, mode) = (surf.format().channels(), surf.format().mode); if k >= n { return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } @@ -105,7 +107,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?; for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); - let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); + let v = crate::channel_cmds::tone(mode, photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]])); if let Some(c) = dst.get_mut(k) { *c += (v - *c) * w.clamp(0.0, 1.0); } diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index 3d89c8f647..a5e687b297 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -72,6 +72,60 @@ fn alpha_channels_copy_and_paste_into_colour_channels() { assert!(near(&px(&s, 30, 15), &[0.0, 0.0, 0.0, 1.0])); } +#[test] +fn cmyk_channels_copy_and_paste_as_tone() { + // #5787: copying a CMYK channel gives its tone (white where there is no ink), and pasting a + // tone into a CMYK channel writes it back as ink. + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + s.execute("channel.target", json!({"channel": "cyan"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let clip = &s.clipboard.as_ref().unwrap().surface; + assert!((clip.pixel(30, 5)[0] - 1.0).abs() < 1e-3, "depth {depth}: no ink copies as white"); + assert!((clip.pixel(5, 5)[0] - (1.0 - left[0])).abs() < 1e-3, "depth {depth}: ink copies dark"); + // Cyan pasted into Black: black gets cyan's ink, and white stays white. + s.execute("channel.target", json!({"channel": "black"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(near(&px(&s, 30, 5), &right), "depth {depth}: no ink stays no ink, {:?}", px(&s, 30, 5)); + let p = px(&s, 5, 5); + assert!((p[3] - left[0]).abs() < 2e-3 && near(&p[..3], &left[..3]), "depth {depth}: black = old cyan, {p:?} vs {left:?}"); + } +} + +#[test] +fn spot_channels_copy_and_paste_as_tone() { + // #5787: a spot channel stores ink but copies as its tone, and a tone pasted into it goes back + // to ink: spot -> Black prints the spot's ink in black, Cyan -> spot gives the spot Cyan's ink. + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap(); + let cyan = px(&s, 5, 5)[0]; + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": H})).unwrap(); + let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": spot})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let clip = &s.clipboard.as_ref().unwrap().surface; + assert!(clip.pixel(5, 5)[0] < 1e-3 && (clip.pixel(30, 5)[0] - 1.0).abs() < 1e-3, "depth {depth}: ink copies dark"); + s.execute("channel.target", json!({"channel": "black"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!((px(&s, 5, 5)[3] - 1.0).abs() < 2e-3 && px(&s, 30, 5)[3] < 2e-3, "depth {depth}: black = the spot's ink"); + s.execute("channel.target", json!({"channel": "cyan"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": spot})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let ink = |x| s.active().unwrap().doc.channels[spot as usize].surface.sample_channel(x, 5, 0); + assert!(near(&[ink(5), ink(15), ink(30)], &[cyan, cyan, 0.0]), "depth {depth}: spot = Cyan's ink, {:?}", [ink(5), ink(15), ink(30)]); + } +} + #[test] fn copy_an_alpha_channel_without_a_pixel_layer() { let mut s = session(8); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 2fc665d575..1d0f41bf3e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -82,6 +82,7 @@ pub mod file_cmds; pub mod fill_cmds; pub mod fill_key_cmds; pub mod float_cmds; +pub mod font_library_cmds; pub(super) mod frame_cmds; pub mod fx_view_cmds; pub mod fx_visibility_cmds; @@ -90,6 +91,7 @@ pub mod gradient_fill_cmds; pub mod group_view_cmds; pub mod history_cmds; pub mod history_menu_cmds; +pub mod history_options_cmds; pub mod history_select_cmds; pub mod history_source_cmds; pub mod image_cmds; @@ -437,26 +439,41 @@ fn build() -> Vec { "Layer…", ["Layer", "New"], Some("Cmd+Shift+N"), - r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)}"##, + r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)} (above the active layer, or the top layer inside it when it is an open group)"##, has_doc, crate::new_layer::run ), - cmd!("layer.new.group", "Group…", ["Layer", "New"], None, r##"{"name":str?}"##, has_doc, |s, p| { - let id = s.edit("New Group", |doc, active| { - let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| doc.next_layer_name("Group")); - let id = doc.insert_above(*active, Layer::group(name, vec![])); - *active = Some(id); - Ok(id) - })?; - crate::layer_multi_cmds::note_inert_insert(s, id); - Ok(json!({ "layer": id.0 })) - }), + cmd!( + "layer.new.group", + "Group…", + ["Layer", "New"], + None, + r##"{"name":text?,"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":str="passThrough" (blend mode),"opacity":0..100=100 (percent),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)} (above the active layer, or inside it when it is an open group; clipped when it lands inside a clipping mask)"##, + has_doc, + |s, p| { + let opts = crate::group_options::GroupOptions::parse("layer.new.group", p)?; + let below = crate::group_options::below("layer.new.group", p)?; + let place = if below { crate::layer_place::Place::Below } else { crate::layer_place::Place::Auto }; + let id = s.edit("New Group", |doc, active| { + let name = opts.name.clone().unwrap_or_else(|| doc.next_layer_name("Group")); + let mut group = Layer::group(name, vec![]); + opts.apply(&mut group); + let bg = crate::background_cmds::background_id(doc); + let id = crate::layer_place::insert_new(doc, *active, group, place); + crate::background_cmds::keep_background_at_bottom(bg, doc)?; + *active = Some(id); + Ok(id) + })?; + crate::layer_multi_cmds::note_inert_insert(s, id); + Ok(json!({ "layer": id.0 })) + } + ), cmd!( "layer.groupLayers", "Group Layers", ["Layer"], Some("Cmd+G"), - r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + r##"{"layer":id?,"name":text?,"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":str="passThrough" (blend mode),"opacity":0..100=100 (percent)} (no layer: every selected layer; the new group starts collapsed)"##, has_layer, crate::layer_multi_cmds::group_layers ), @@ -592,7 +609,11 @@ fn build() -> Vec { let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); // Clipping and channel changes reach the layers around it: recomposite everything. let local = p.get("clipped").is_none() && p.get("channels").is_none(); - s.edit(label, |doc, _| { + // The eye: a history state only with History Options › Make Layer Visibility + // Changes Undoable. + let only_visibility = changed.as_slice() == ["visible"]; + let record = !only_visibility || s.visibility_undoable(); + s.edit_with(label, record, |doc, _| { // The Background has no opacity, fill or blend mode: like Photoshop, setting // one that changes its look turns it into a normal layer ("Layer 0") first. let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0); @@ -782,7 +803,7 @@ fn build() -> Vec { }; let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) })?; @@ -814,7 +835,7 @@ fn build() -> Vec { .in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) })?; @@ -906,7 +927,7 @@ fn build() -> Vec { "Brush Stroke", [], None, - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"fill":bool=false (fill the region the closed `points` enclose with the brush colour, its edge anti-aliased and softened by hardness, instead of stamping the outline; a closed gesture paints a filled dab, not a ring),"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke ), @@ -1271,7 +1292,9 @@ fn build() -> Vec { v.extend(crate::distort_cmds::specs()); v.extend(crate::analysis_cmds::specs()); v.extend(crate::notes_cmds::specs()); + v.extend(crate::font_library_cmds::specs()); v.extend(crate::history_cmds::specs()); + v.extend(crate::history_options_cmds::specs()); v.extend(crate::history_menu_cmds::specs()); v.extend(crate::history_source_cmds::specs()); v.extend(crate::history_select_cmds::specs()); diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 830d601b5f..5bbd7df961 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -10,6 +10,7 @@ //! result through the selection like the other Image › Adjustments commands. use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights}; +use photocraft_doc::adjust::{LutFormat, LutSource}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect}; use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; @@ -104,34 +105,38 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result { const CMD: &str = "colorLookup"; - let (mut name, mut lut, mut size, mut tetrahedral, mut dither) = match base { - Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither }) => (name.clone(), lut.clone(), *size, *tetrahedral, *dither), - _ => (String::new(), None, 0, false, false), + let (mut name, mut lut, mut size, mut tetrahedral, mut dither, mut source) = match base { + Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, source }) => { + (name.clone(), lut.clone(), *size, *tetrahedral, *dither, source.clone()) + } + _ => (String::new(), None, 0, false, false, None), }; - let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String)> = None; + // The parsed table, its label and (for a file) the file as loaded, which a PSD save embeds. + let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String, Option)> = None; if let Some(id) = opt_str(CMD, p, "lut")? { if id == "none" || id.is_empty() { name.clear(); lut = None; size = 0; + source = None; } else { let f = photocraft_cms::lutfile::builtin(id).ok_or_else(|| bad(CMD, format!("unknown look `{id}` (built-ins: {})", builtin_ids())))?; let label = f.title.clone(); - loaded = Some((f, label)); + loaded = Some((f, label, None)); } } let file_name = opt_str(CMD, p, "fileName")?; if let Some(text) = opt_str(CMD, p, "data")? { let file_name = file_name.unwrap_or("lut.cube"); let f = photocraft_cms::lutfile::parse(file_name, text.as_bytes()).map_err(|e| bad(CMD, e.0))?; - loaded = Some((f, base_name(file_name))); + loaded = Some((f, base_name(file_name), Some(lut_source(file_name, text.as_bytes().to_vec())))); } if let Some(path) = opt_str(CMD, p, "file")?.filter(|s| !s.is_empty()) { let bytes = read_file(path).ok_or_else(|| bad(CMD, format!("can't read {path}")))?; let f = photocraft_cms::lutfile::parse(path, &bytes).map_err(|e| bad(CMD, format!("{path}: {}", e.0)))?; - loaded = Some((f, base_name(path))); + loaded = Some((f, base_name(path), Some(lut_source(path, bytes)))); } - if let Some((f, label)) = loaded { + if let Some((f, label, src)) = loaded { if !f.domain_is_default() { return Err(bad( CMD, @@ -144,6 +149,7 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result {} @@ -157,7 +163,17 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> LutSource { + let format = match photocraft_cms::lutfile::Format::detect(file_name, &bytes) { + photocraft_cms::lutfile::Format::Cube => LutFormat::Cube, + photocraft_cms::lutfile::Format::ThreeDl => LutFormat::ThreeDl, + photocraft_cms::lutfile::Format::Look => LutFormat::Look, + }; + LutSource { format, bytes: std::sync::Arc::new(bytes) } } fn builtin_ids() -> String { diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index 312caedc32..e18e37f1ec 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -170,7 +170,7 @@ fn color_lookup_builtin_data_and_errors() { // Switching interpolation keeps the table. s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap(); match &doc(&s).layer(id).unwrap().content { - LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name }) => { + LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name, .. }) => { assert_eq!(name, "Day for Night") } other => panic!("{other:?}"), @@ -220,6 +220,53 @@ LUT_3D_SIZE 2 } /// The kind's parameters, read back from the model (Color Lookup with its table name and size). +#[test] +fn color_lookup_file_round_trips_psd_in_its_own_format() { + use photocraft_doc::adjust::{LutFormat, LutSource}; + // #6544: a .3dl / .look file is kept as loaded and embedded unchanged in a PSD. + let mut threedl = String::from("0 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + threedl.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095)); + } + } + } + let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let look = format!("2{hex}"); + for (text, file_name, format, psd_format) in + [(threedl.as_str(), "Invert.3dl", LutFormat::ThreeDl, "LUTFormat3DL"), (look.as_str(), "Grade.look", LutFormat::Look, "LUTFormatLOOK")] + { + let mut s = session(8, "rgb"); + let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": text, "fileName": file_name})).unwrap(); + let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); + let want = LutSource { format, bytes: std::sync::Arc::new(text.as_bytes().to_vec()) }; + let source_of = |s: &Session| match adjustment_of(s, id) { + Adjustment::ColorLookup { source, .. } => source, + other => panic!("{other:?}"), + }; + assert_eq!(source_of(&s), Some(want.clone()), "{file_name}"); + // Settings that don't touch the table keep the file. + s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap(); + assert_eq!(source_of(&s), Some(want.clone()), "{file_name}"); + // Saved as PSD and reopened: same format tag, same bytes, same name. + let psd = photocraft_io::export(doc(&s), "psd", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let back = photocraft_io::import("x.psd", &psd).unwrap().document; + let layer = back.walk().into_iter().map(|(_, _, l)| l).find(|l| matches!(l.content, LayerContent::Adjustment(Adjustment::ColorLookup { .. }))).unwrap(); + let LayerContent::Adjustment(Adjustment::ColorLookup { name, source, .. }) = &layer.content else { panic!("not a Color Lookup") }; + assert_eq!((name.as_str(), source.as_ref()), (file_name, Some(&want)), "{psd_format}"); + // Choosing a built-in look replaces the table, so the file goes too. + s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "dayForNight"})).unwrap(); + assert_eq!(source_of(&s), None); + } + // "none" drops it as well. + let mut s = session(8, "rgb"); + let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": threedl, "fileName": "Invert.3dl"})).unwrap(); + let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "none"})).unwrap(); + assert!(matches!(adjustment_of(&s, id), Adjustment::ColorLookup { source: None, lut: None, .. })); +} + fn adjustment_of(s: &Session, id: photocraft_doc::LayerId) -> Adjustment { match &doc(s).layer(id).unwrap().content { LayerContent::Adjustment(a) => a.clone(), diff --git a/crates/engine/src/cmd/adjust_params.rs b/crates/engine/src/cmd/adjust_params.rs index d70ff79dcb..ae1ba0a2b8 100644 --- a/crates/engine/src/cmd/adjust_params.rs +++ b/crates/engine/src/cmd/adjust_params.rs @@ -1,6 +1,6 @@ use super::enabled::{check_all_unlocked, is_mask_target, paint_surface}; use super::layer_params::{layer_param, mask_new_layer}; -use super::*; +use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels}; /// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask). pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result { @@ -9,7 +9,7 @@ pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: & let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); mask_new_layer(doc, &mut l, p, cmd, true)?; - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/align_cmds.rs b/crates/engine/src/cmd/align_cmds.rs index 869d29b724..1cddbb51cd 100644 --- a/crates/engine/src/cmd/align_cmds.rs +++ b/crates/engine/src/cmd/align_cmds.rs @@ -313,7 +313,7 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { surf.write_region(area, px); surf.prune(); } - below = doc.insert_above(Some(below), l); + below = crate::layer_place::insert_new(doc, Some(below), l, crate::layer_place::Place::Above); below }); let Some(px) = &blended else { @@ -327,7 +327,7 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { surf.write_region(area, px); surf.prune(); } - let nid = doc.insert_above(Some(below), l); + let nid = crate::layer_place::insert_new(doc, Some(below), l, crate::layer_place::Place::Above); *active = Some(nid); Ok((Some(nid), depth_id)) })?; diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index 8932295d52..defaa834d4 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -197,7 +197,8 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess Ok(v) => { // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); - st.history.record(label, before, st.layer_target()); + st.history.record(label, before.clone(), st.layer_target()); + st.history.add_current_visibility(&crate::visibility_changes(&before, &st.doc)); st.trim_history(); st.coalesce = None; Ok(v) diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index 5394c0193c..2f05ddfae0 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -320,12 +320,35 @@ fn artboard_from_group(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } +/// The layers' union bounds, or the canvas when they have none: the board Artboard from Layers +/// makes when no size is given. +fn union_bounds<'a>(layers: impl IntoIterator, canvas: Rect) -> Rect { + layers.into_iter().filter_map(crate::layer_multi_cmds::layer_bounds).reduce(|a, b| a.union(&b)).unwrap_or(canvas) +} + +/// What Artboard from Layers' dialog opens with, as in the reference application: the name the +/// board would get and the selected top-level layers' size (`None` without a document). +pub fn from_layers_defaults(s: &Session) -> Option<(String, [i64; 2])> { + let d = s.active()?; + let ids = crate::layer_multi_cmds::top_level(&d.doc, &crate::layer_multi_cmds::selected(s)); + let [_, _, w, h] = xywh(union_bounds(ids.iter().filter_map(|id| d.doc.layer(*id)), d.doc.bounds())); + Some((next_artboard_name(&d.doc), [w, h])) +} + fn artboard_from_layers(s: &mut Session, p: &Value) -> Result { let sel = crate::layer_multi_cmds::selected(s); let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let background = background_param(p, "layer.new.artboardFromLayers")?.unwrap_or_default(); + let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string); + let cmd = "layer.new.artboardFromLayers"; + let background = background_param(p, cmd)?.unwrap_or_default(); + // `int` rounds, so a small negative (-0.4) would pass as 0, "keep the bounds". + if ["width", "height"].iter().any(|k| p.get(*k).and_then(Value::as_f64).is_some_and(|v| v < 0.0)) { + return Err(bad(cmd, "the artboard width and height must not be negative")); + } + // A size of the user's own (a post, a story…) from the layers' top left; 0 or none keeps + // their bounds on that axis (#9087). + let size = [int(p, "width").filter(|v| *v != 0), int(p, "height").filter(|v| *v != 0)]; let (gid, rect) = s.edit("Artboard from Layers", |doc, active| { let ids = crate::layer_multi_cmds::top_level(doc, &sel); if ids.is_empty() || ids.iter().any(|id| doc.artboard_of(*id).is_some()) { @@ -335,7 +358,8 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result { for id in &ids { children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?); } - let rect = children.iter().filter_map(crate::layer_multi_cmds::layer_bounds).reduce(|a, b| a.union(&b)).unwrap_or(canvas); + let [x, y, w, h] = xywh(union_bounds(&children, canvas)); + let rect = board_rect(cmd, [x, y, size[0].unwrap_or(w), size[1].unwrap_or(h)])?; let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), children); set_artboard(&mut g, Artboard { rect, background, preset: String::new() }); let gid = doc.insert_above(None, g); @@ -687,7 +711,7 @@ pub fn specs() -> Vec { "layer.new.artboardFromLayers", "Artboard from Layers…", &["Layer", "New"], - r##"{"name":str?,"background":…?} (the selected layers) → {layer, rect}"##, + r##"{"name":text,"background":"white|black|transparent|custom"="white","color":color,"width":px=0,"height":px=0} (the selected layers; name: empty gives Artboard N; color: for custom; width, height: the board's size from the layers' top left, 0 keeps their bounds) → {layer, rect}"##, has_free_layers, artboard_from_layers ), diff --git a/crates/engine/src/cmd/artboard_cmds/tests.rs b/crates/engine/src/cmd/artboard_cmds/tests.rs index ad3f7a46d0..d21011a71b 100644 --- a/crates/engine/src/cmd/artboard_cmds/tests.rs +++ b/crates/engine/src/cmd/artboard_cmds/tests.rs @@ -690,3 +690,43 @@ fn new_document_with_an_artboard() { assert!(doc(&s).artboards().is_empty()); assert!(s.execute("file.new", json!({"artboard": "yes"})).is_err()); } + +/// #9087: Artboard from Layers takes a size of the user's own, from the layers' top left; 0 or +/// none keeps their bounds on that axis, and a negative size is refused. +#[test] +fn artboard_from_layers_takes_a_custom_size() { + for (params, want) in [ + (json!({"width": 108, "height": 135}), [10, 10, 108, 135]), + (json!({"width": 0, "height": 0}), [10, 10, 20, 20]), + (json!({"width": 60}), [10, 10, 60, 20]), + ] { + let mut s = session(8); + add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + let r = s.execute("layer.new.artboardFromLayers", params.clone()).unwrap(); + assert_eq!(r["rect"], json!(want), "{params}"); + } + let mut s = session(8); + add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + assert!(s.execute("layer.new.artboardFromLayers", json!({"width": -5})).is_err()); + // `int` rounds -0.4 to 0, which would otherwise keep the bounds. + assert!(s.execute("layer.new.artboardFromLayers", json!({"height": -0.4})).is_err()); + assert!(doc(&s).artboards().is_empty(), "a refused size changes nothing"); + assert_eq!(s.execute("layer.new.artboardFromLayers", json!({"name": ""})).unwrap()["rect"], json!([10, 10, 20, 20])); + assert_eq!(doc(&s).artboards()[0].1, "Artboard 1", "an empty name gives Artboard N"); +} + +/// The dialog's defaults (#9087): the selected layers' size and the board's name, as the command +/// makes it with no size; the canvas when the layers have no pixels. +#[test] +fn from_layers_defaults_are_the_selected_layers_size() { + let mut s = session(8); + add_square(&mut s, Rect::new(10, 10, 30, 40), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(from_layers_defaults(&s), Some(("Artboard 1".to_string(), [20, 30]))); + s.execute("layer.new.artboardFromLayers", json!({})).unwrap(); + add_square(&mut s, Rect::new(50, 5, 60, 10), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(from_layers_defaults(&s), Some(("Artboard 2".to_string(), [10, 5]))); + let mut s = session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(from_layers_defaults(&s), Some(("Artboard 1".to_string(), [100, 80])), "an empty layer: the canvas"); + assert_eq!(from_layers_defaults(&Session::new()), None); +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 32e5405d1a..7c717701bf 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -38,7 +38,7 @@ mod steps_make; mod tables; #[cfg(test)] -use adjustments::selective_from; +use adjustments::{levels_from, selective_from}; pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; @@ -56,7 +56,7 @@ use files::{load, save}; #[cfg(test)] use steps_doc::crop_from; #[cfg(test)] -use steps_image::transform_to; +use steps_image::{apply_image_from, transform_to}; #[cfg(test)] use steps_layer::quick_mask_ref; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/adjustments.rs b/crates/engine/src/cmd/atn_cmds/adjustments.rs index 321ca5ebc4..5da802b800 100644 --- a/crates/engine/src/cmd/atn_cmds/adjustments.rs +++ b/crates/engine/src/cmd/atn_cmds/adjustments.rs @@ -1,7 +1,7 @@ use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, long, num, pct, text}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, channel_key, channel_ref, lookup, lookup_back}; -use super::*; +use super::{D, Descriptor, Map, Value, json}; // ------------------------------------------------------------------ adjustments diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index b23c578ac6..78d65979bf 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -1,7 +1,7 @@ use super::descriptors::{desc, get_obj, jstr}; use super::events::{event_and_descriptor, event_for}; use super::fields::Mapped; -use super::*; +use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, D, DEFAULT_SET, Descriptor, PHOTOSHOP_STEP, UnicodeString, Value, atn, json}; // ------------------------------------------------------------------ import diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 0fbecf60da..9e9835218b 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Class, D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; // ------------------------------------------------------------------ descriptor access diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 6d2fbc4382..3760e6916e 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -16,7 +16,7 @@ use super::steps_layer::{ }; use super::steps_make::{make_from, make_to, set_from, set_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; -use super::*; +use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json}; /// An event: code (empty when Photoshop only has a string name), string name, the panel /// label, the command ids it exports (the first is the one simple events import to), and the diff --git a/crates/engine/src/cmd/atn_cmds/fields.rs b/crates/engine/src/cmd/atn_cmds/fields.rs index 8dd32a283c..dcce669a3f 100644 --- a/crates/engine/src/cmd/atn_cmds/fields.rs +++ b/crates/engine/src/cmd/atn_cmds/fields.rs @@ -1,6 +1,6 @@ use super::descriptors::{ang, color_desc, color_hex, enm, get_bool, get_enum, get_num, get_obj, long, pct, px}; use super::tables::{blend_label, blend_ps, lookup, lookup_back}; -use super::*; +use super::{D, Descriptor, Map, Value, json}; // ------------------------------------------------------------------ field tables diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 9666a443bb..be7bd45151 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,5 +1,5 @@ use super::convert::{UniqueNames, export, import}; -use super::*; +use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; pub(super) fn load(s: &mut Session, p: &Value) -> Result { let cmd = "actions.load"; diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 36100c1066..12852536ca 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -5,7 +5,7 @@ use super::descriptors::{ }; use super::fields::{Mapped, none, step, steps}; use super::tables::{ALIGN, EXTENSION, INTENT, INTERPOLATION, MODES, PROFILES, PURGE, SAVE_FORMATS, lookup, lookup_back}; -use super::*; +use super::{D, Descriptor, Id, Map, ReferenceItem, STOP_MESSAGE, UnicodeString, Value, json}; /// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale. pub(super) fn place_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index bb8e1c82fd..a1b808bf34 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -4,8 +4,8 @@ use super::descriptors::{ }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; -use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, lookup, lookup_back}; -use super::*; +use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; +use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; /// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, /// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything @@ -143,7 +143,7 @@ pub(super) fn apply_image_from(d: &Descriptor) -> Mapped { match item { ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => { let v = trimmed(value); - let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; + let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| channel_of(&v)) else { return unsupported }; source.insert("channel".into(), channel.into()); } // A named (alpha) channel. diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 36e77d624b..9dbdad579b 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -7,7 +7,7 @@ use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_to, none, step, steps}; use super::steps_make::is_tool_class; use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; -use super::*; +use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 696a1f17f8..96f9033102 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -6,7 +6,7 @@ use super::descriptors::{ use super::fields::{Mapped, none, step, steps}; use super::steps_layer::quick_mask_ref; use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back}; -use super::*; +use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json}; /// A `patternLayer` descriptor's pattern, scale, angle and link as the params of /// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ @@ -59,82 +59,92 @@ const MASK_USING: &[(&str, &str)] = &[ ("HdSl", "layer.layerMask.hideSelection"), ]; +fn duplicate_layer_from_descriptor(d: &Descriptor) -> Option { + let r = get_ref(d, "null")?; + if ref_class(r).as_deref() != Some("Dcmn") || !matches!(target(r), Target::Class) { + return None; + } + let u = get_ref(d, "Usng")?; + if !matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) { + return None; + } + let mut params = layer_target(u)?; + params.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + params.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + params.insert("name".into(), n.into()); + } + Some(step("layer.duplicate", Value::Object(params))) +} + +fn mask_from_descriptor(d: &Descriptor) -> Option { + let Some(D::Class(c)) = d.get("Nw ") else { return None }; + if trimmed(&c.class_id) != "Chnl" { + return None; + } + let at_mask = + get_ref(d, "At ").is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk")); + Some(match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) { + Some(id) if at_mask => none(id), + _ => Mapped::Unsupported { harmless: false }, + }) +} + +fn new_document_from_descriptor(d: &Descriptor) -> Option { + let nw = get_obj(d, "Nw ")?; + if get_enum(d, "Usng").as_deref() == Some("Slct") { + let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; + let mut mapped_steps = vec![(id, json!({}))]; + if let Some(n) = get_text(nw, "Nm ") { + mapped_steps.push(("layer.setProps", json!({"name": n}))); + } + return Some(steps(mapped_steps)); + } + if get_text(nw, "preset").is_some() { + return Some(none("file.newFromClipboard")); + } + let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) else { + return Some(Mapped::Unsupported { harmless: false }); + }; + let res = get_num(nw, "Rslt").unwrap_or(72.0); + let to_px = |key: &str, value: f64| match nw.get(key) { + Some(D::UnitFloat { unit: PX, .. }) => value, + _ => value * res / 72.0, + }; + let mut params = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res}); + if let Some(n) = get_text(nw, "Nm ") { + params["name"] = json!(n); + } + if let Some(D::Class(c)) = nw.get("Md ") + && let Some(m) = lookup(MODES, &trimmed(&c.class_id)) + { + params["mode"] = json!(m); + } + if let Some(depth) = get_num(nw, "Dpth") { + params["depth"] = json!(depth); + } + if let Some(fill) = get_enum(nw, "Fl ") { + params["background"] = json!(match fill.as_str() { + "Wht" => "white", + "BckC" => "backgroundColor", + "Trns" => "transparent", + _ => "white", + }); + } + Some(step("file.new", params)) +} + pub(super) fn make_from(d: &Descriptor) -> Mapped { - // Duplicate layer/group into a new document: `Mk` with a document class, a layer - // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` - // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another - // open document) stays a kept Photoshop step: document indices mean nothing at replay. - if let Some(r) = get_ref(d, "null") - && ref_class(r).as_deref() == Some("Dcmn") - && matches!(target(r), Target::Class) - && let Some(u) = get_ref(d, "Usng") - && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) - && let Some(mut p) = layer_target(u) - { - p.insert("document".into(), "new".into()); - if let Some(n) = get_text(d, "Nm ") { - p.insert("documentName".into(), n.into()); - } - if let Some(n) = get_text(d, "LyrN") { - p.insert("name".into(), n.into()); - } - return step("layer.duplicate", Value::Object(p)); + if let Some(mapped) = duplicate_layer_from_descriptor(d) { + return mapped; } - // Add Layer Mask: a `Chnl` class made at the mask channel, `Usng` what it reveals. - if let Some(D::Class(c)) = d.get("Nw ") - && trimmed(&c.class_id) == "Chnl" - { - // The target layer's mask only: a further item (a `Lyr` by name…) puts it on another layer. - let at_mask = get_ref(d, "At ") - .is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk")); - return match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) { - Some(id) if at_mask => none(id), - _ => Mapped::Unsupported { harmless: false }, - }; + if let Some(mapped) = mask_from_descriptor(d) { + return mapped; } - if let Some(nw) = get_obj(d, "Nw ") { - // Layer via Copy / Cut (`Usng` = the selection), or a new document. - if get_enum(d, "Usng").as_deref() == Some("Slct") { - let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; - let mut v = vec![(id, json!({}))]; - if let Some(n) = get_text(nw, "Nm ") { - v.push(("layer.setProps", json!({"name": n}))); - } - return steps(v); - } - if get_text(nw, "preset").is_some() { - return none("file.newFromClipboard"); - } - if let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) { - let res = get_num(nw, "Rslt").unwrap_or(72.0); - // Dimensions are in points (`#Rlt`) unless given in pixels. - let to_px = |k: &str, v: f64| match nw.get(k) { - Some(D::UnitFloat { unit: PX, .. }) => v, - _ => v * res / 72.0, - }; - let mut p = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res}); - if let Some(n) = get_text(nw, "Nm ") { - p["name"] = json!(n); - } - if let Some(D::Class(c)) = nw.get("Md ") - && let Some(m) = lookup(MODES, &trimmed(&c.class_id)) - { - p["mode"] = json!(m); - } - if let Some(depth) = get_num(nw, "Dpth") { - p["depth"] = json!(depth); - } - if let Some(f) = get_enum(nw, "Fl ") { - p["background"] = json!(match f.as_str() { - "Wht" => "white", - "BckC" => "backgroundColor", - "Trns" => "transparent", - _ => "white", - }); - } - return step("file.new", p); - } - return Mapped::Unsupported { harmless: false }; + if let Some(mapped) = new_document_from_descriptor(d) { + return mapped; } let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let using = get_obj(d, "Usng"); @@ -316,84 +326,91 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { }) } +fn selection_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + match d.get("T ") { + Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() { + "Al" => none("select.all"), + "None" => none("select.deselect"), + "Prvs" => none("select.reselect"), + _ => unsupported, + }, + Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { + (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + // ⌘-click on the targeted channel (a layer mask after `select` of `Msk`). + (Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})), + (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), + _ => unsupported, + }, + Some(D::Descriptor(t)) if t.class_id.is("Plgn") => { + let pts: Option> = get_list(t, "Pts ") + .unwrap_or(&[]) + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + match pts { + Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})), + _ => unsupported, + } + } + Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { + let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { + return unsupported; + }; + let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)}); + if t.class_id.is("Elps") { + p["ellipse"] = json!(true); + } + step("select.rect", p) + } + _ => unsupported, + } +} + +fn background_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + // On an image that opened as a normal layer there is no Background to convert; + // Photoshop stops there, but the step's purpose (an editable layer) is met. + // Photoshop's `set` of another layer doesn't target it: the selected layer stays. + let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))]; + let mut props = Map::new(); + props.insert("layer".into(), "Layer 0".into()); + if let Some(t) = get_obj(d, "T ") { + if let Some(n) = get_text(t, "Nm ") { + props.insert("name".into(), n.into()); + } + if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) { + props.insert("opacity".into(), (o / 100.0).into()); + } + match get_enum(t, "Md ").as_deref() { + None | Some("Nrml") => {} + Some(m) => match blend_label(m) { + Some(label) => { + props.insert("blend".into(), label.into()); + } + None => return unsupported, + }, + } + } + if props.len() > 1 { + v.push(("layer.setProps", Value::Object(props))); + } + steps(v) +} + pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; match (ref_class(r).as_deref(), target(r)) { - (Some("Chnl"), Target::Property(p)) if p == "fsel" => match d.get("T ") { - Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() { - "Al" => none("select.all"), - "None" => none("select.deselect"), - "Prvs" => none("select.reselect"), - _ => unsupported, - }, - Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { - (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), - // ⌘-click on the targeted channel (a layer mask after `select` of `Msk`). - (Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})), - (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), - _ => unsupported, - }, - Some(D::Descriptor(t)) if t.class_id.is("Plgn") => { - let pts: Option> = get_list(t, "Pts ") - .unwrap_or(&[]) - .iter() - .map(|v| match v { - D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), - _ => None, - }) - .collect(); - match pts { - Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})), - _ => unsupported, - } - } - Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { - let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) - else { - return unsupported; - }; - let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)}); - if t.class_id.is("Elps") { - p["ellipse"] = json!(true); - } - step("select.rect", p) - } - _ => unsupported, - }, + (Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d), // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), // Layer › New › Layer From Background (Photoshop records the New Layer dialog's values // as a `set` of the Background); values other than the defaults then apply to the layer. - (Some("Lyr"), Target::Property(p)) if p == "Bckg" => { - // On an image that opened as a normal layer there is no Background to convert; - // Photoshop stops there, but the step's purpose (an editable layer) is met. - // Photoshop's `set` of another layer doesn't target it: the selected layer stays. - let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))]; - let mut props = Map::new(); - props.insert("layer".into(), "Layer 0".into()); - if let Some(t) = get_obj(d, "T ") { - if let Some(n) = get_text(t, "Nm ") { - props.insert("name".into(), n.into()); - } - if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) { - props.insert("opacity".into(), (o / 100.0).into()); - } - match get_enum(t, "Md ").as_deref() { - None | Some("Nrml") => {} - Some(m) => match blend_label(m) { - Some(label) => { - props.insert("blend".into(), label.into()); - } - None => return unsupported, - }, - } - } - if props.len() > 1 { - v.push(("layer.setProps", Value::Object(props))); - } - steps(v) - } + (Some("Lyr"), Target::Property(p)) if p == "Bckg" => background_from(d), // The toolbox colours. (Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) { Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})), diff --git a/crates/engine/src/cmd/atn_cmds/tables.rs b/crates/engine/src/cmd/atn_cmds/tables.rs index 13b4bb9e8d..0f3bc278e9 100644 --- a/crates/engine/src/cmd/atn_cmds/tables.rs +++ b/crates/engine/src/cmd/atn_cmds/tables.rs @@ -1,5 +1,5 @@ use super::descriptors::{Target, class, code, get_ref, target}; -use super::*; +use super::{D, Descriptor, ReferenceItem}; /// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. const BLEND_MODES: &[(&str, &str)] = &[ @@ -78,17 +78,24 @@ pub(super) const CHANNELS: &[(&str, &str)] = &[ ("Mgnt", "magenta"), ("Yllw", "yellow"), ("Blck", "black"), - ("Lmnc", "lightness"), + // Lab Lightness: Photoshop rejects `Lmnc` ("luminance") on a Lab document (#9384). + ("Lght", "lightness"), ("A", "a"), ("B", "b"), ]; +/// The channel key of a `Chnl` enumeration. `Lmnc` is what PhotoCraft wrote for Lightness before +/// #9384; reading it keeps those actions loading. +pub(super) fn channel_of(code: &str) -> Option<&'static str> { + lookup(CHANNELS, code).or_else(|| (code.trim_end() == "Lmnc").then_some("lightness")) +} + /// The channel an adjustment entry's `Chnl` reference names: `None` = the composite. pub(super) fn channel_key(d: &Descriptor) -> Option> { let r = get_ref(d, "Chnl")?; Some(match target(r) { Target::Ordinal(o) if o == "Trgt" || o == "Cmps" => None, - Target::Ordinal(o) => Some(lookup(CHANNELS, &o)?), + Target::Ordinal(o) => Some(channel_of(&o)?), _ => return None, }) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index afd3895393..08286b5329 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -2644,6 +2644,44 @@ fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() { assert_eq!(channels(&curves), vec![composite, red]); } +/// A Lab document's Lightness channel is `Chnl.Lght` (#9384): Photoshop plays a Levels or Apply +/// Image step naming it and rejects `Chnl.Lmnc` ("luminance"), which PhotoCraft wrote before. +/// Steps PhotoCraft saved with `Lmnc` still load. +#[test] +fn lab_lightness_channel_is_written_and_read_as_photoshop_names_it() { + let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export")); + let entry_codes = |d: &Descriptor| -> Vec { + get_list(d, "Adjs") + .unwrap_or_default() + .iter() + .filter_map(|e| match e { + D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? { + ReferenceItem::Enumerated { value, .. } => Some(trimmed(value)), + _ => None, + }, + _ => None, + }) + .collect() + }; + let levels = exported("image.adjustments.levels", json!({"lightness": {"gamma": 1.5}, "a": {"inBlack": 3}})); + assert_eq!(entry_codes(&levels), ["Cmps", "Lght", "A"]); + let curves = exported("image.adjustments.curves", json!({"lightness": [[0, 10], [255, 245]]})); + assert_eq!(entry_codes(&curves), ["Lght"]); + let apply = exported("image.applyImage", json!({"sourceLayer": "merged", "sourceChannel": "Lightness"})); + let source = get_ref(get_obj(&apply, "With").unwrap(), "T ").unwrap(); + assert!(matches!(&source[0], ReferenceItem::Enumerated { value, .. } if trimmed(value) == "Lght"), "{source:?}"); + + // What Photoshop records, and what PhotoCraft wrote before, both read as Lightness. + let chnl = |c: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(c) }]); + for c in ["Lght", "Lmnc"] { + let lvls = desc("Lvls").with("Adjs", D::List(vec![D::Descriptor(desc("LvlA").with("Chnl", chnl(c)).with("Gmm ", D::Double(1.5)))])); + assert_eq!(levels_from(&lvls), Some(json!({"lightness": {"gamma": 1.5}})), "{c}"); + let with = desc("Clcl").with("T ", chnl(c)); + let Mapped::Steps(steps) = apply_image_from(&desc("null").with("With", D::Descriptor(with))) else { panic!("{c}: Apply Image does not import") }; + assert_eq!(steps[0].1["source"]["channel"], "lightness", "{c}"); + } +} + /// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one. #[test] fn set_colors_exports_only_a_single_colour() { diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 33a64bfc36..479e33790e 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -429,9 +429,93 @@ pub fn paint_stroke(s: &mut Session, p: &Value) -> Result { let pts = parse_points(p, "paint.stroke")?; let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p); let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" }; + if flag(p, "fill", false) { + return fill_region(s, p, label, brush, pts); + } stroke_with(s, "paint.stroke", p, label, brush, pts, false) } +/// `paint.stroke {fill: true}`: fill the region enclosed by `points` (their polygon) with the +/// brush's colour and opacity, its edge anti-aliased and softened by the brush hardness, instead of +/// stamping the outline. A closed gesture then paints a filled dab, not a ring (#3659). +fn fill_region(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec) -> Result { + if clear_mode(p) { + // `clear` maps onto the eraser; a clear fill would erase the region the outline encloses, + // which a selection + delete does deliberately (#3659). Reject before the eraser check so + // the message names the real conflict. + return Err(bad("paint.stroke", "\"fill\" cannot be combined with \"mode\":\"clear\"; select the enclosed region and delete it instead")); + } + if brush.erase { + return Err(bad("paint.stroke", "\"fill\" does not support the Eraser")); + } + if pts.len() < 3 { + return Err(bad("paint.stroke", "\"fill\" needs at least three points (a closed region)")); + } + let (id, brush, _zoom) = stroke_target(s, p, brush)?; + // A hard brush (hardness 1) keeps the anti-aliased polygon edge; a soft one feathers it by half + // the brush width, the way its tip fades. + let feather_px = ((1.0 - brush.hardness.clamp(0.0, 1.0)) * brush.size * 0.5).max(0.0); + let (color, opacity, mode) = (brush.color, brush.opacity.clamp(0.0, 1.0), brush.mode); + // A colour picked as CMYK fills with its inks in a CMYK target where it covers fully, as + // Edit › Fill does (blend_color_mask blends partial coverage through the colour's RGB). + let picked = brush.color_cmyk.filter(|(rgb, _)| *rgb == [color[0], color[1], color[2]]).map(|(_, inks)| inks); + let cmyk = doc_cmyk_space(s); + let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + s.edit(label, |doc, _| { + let sel = doc.selection.clone(); + let canvas = doc.bounds(); + let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + // The polygon's whole-pixel bounds, grown by the feather's reach (3σ) so a soft edge + // fades out instead of being clipped. + let (mut bx0, mut by0, mut bx1, mut by1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for sp in &pts { + if !(sp.x.is_finite() && sp.y.is_finite()) { + return Err(bad("paint.stroke", "non-finite point in a fill")); + } + bx0 = bx0.min(sp.x); + by0 = by0.min(sp.y); + bx1 = bx1.max(sp.x); + by1 = by1.max(sp.y); + } + let ix = |v: f64| v.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32; + let reach = photocraft_algo::selection::feather_reach(feather_px); + let pad = i32::try_from(reach).unwrap_or(i32::MAX).saturating_add(2); + let bounds = Rect::new( + ix(bx0.floor()).saturating_sub(pad), + ix(by0.floor()).saturating_sub(pad), + ix(bx1.ceil()).saturating_add(pad), + ix(by1.ceil()).saturating_add(pad), + ) + .intersect(&canvas); + if bounds.is_empty() { + return Ok(Rect::new(0, 0, 0, 0)); + } + let (w, h) = (bounds.width() as usize, bounds.height() as usize); + // The coverage, its feathered copy (and the blur's scratch), the selection read and + // the mask are each a buffer over the whole region, which can be the whole canvas: + // reserve that peak first, so a region too big for memory is an error, not an abort. + const FILL: &str = "filling a painted region"; + let peak = w.checked_mul(h).and_then(|n| n.checked_mul(5)).ok_or_else(|| crate::allocation::error(FILL, "requested size overflowed"))?; + drop(crate::allocation::capacity::(peak, FILL)?); + let xy: Vec<(f32, f32)> = pts.iter().map(|sp| (sp.x as f32, sp.y as f32)).collect(); + let mut cov = photocraft_algo::selection::polygon(&xy, bounds, true); + if feather_px > 0.01 { + cov = photocraft_algo::selection::feather(&cov, w, h, feather_px); + } + if let Some(sel) = sel.as_ref() { + let sv = crate::allocation::read_region(sel, bounds, FILL)?; + for (c, s) in cov.iter_mut().zip(&sv) { + *c *= s.clamp(0.0, 1.0); + } + } + let coverage = photocraft_algo::selection::mask_to_surface(&cov, bounds); + crate::fill_cmds::blend_color_mask(surf, bounds, color, picked, &coverage, mode, opacity, lock)?; + Ok(bounds) + }) + })?; + Ok(damage_json(s, dmg)) +} + /// A `paint.stroke` rendered while it is drawn, onto a copy of the active document, so the canvas /// shows the real dabs before the stroke commits. Committing the same params and points with /// `"seed": live.seed` gives the same pixels. diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live.rs b/crates/engine/src/cmd/brush_cmds/mixer_live.rs index acdd897fd4..3c15001553 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live.rs @@ -1,6 +1,9 @@ //! Incremental Mixer Brush transaction. Source pixels are composed before each dab; //! only the selected target changes. Dropping the transaction cancels it. -use super::*; +use super::{ + EngineError, LiveCommit, MixerSettings, Rect, Result, Session, Stroke, StrokePoint, Surface, Value, bad, check_coords, color, damage_json, doc_cmyk_space, + flag, has_paintable, json, layer_id, num, parse_points, pct, resolve_brush, +}; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::{Document, LayerId}; use photocraft_paint::dynamics::DabGenerator; diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index 87504dc0e9..3a3041c4a6 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -31,6 +31,93 @@ fn stroke_backwards_compatible() { assert!(rgba(&s, 30, 5)[3] > 0.9 && rgba(&s, 30, 8)[3] < 0.01); } +#[test] +fn fill_fills_the_enclosed_region_not_the_outline() { + // #3659: a closed gesture with `fill` paints the interior, not a ring. + let mut s = session(60, 60); + s.execute( + "paint.stroke", + json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "size": 4, "color": "#ff0000", "brush": {"hardness": 1.0}}), + ) + .unwrap(); + assert!(rgba(&s, 30, 30)[0] > 0.9 && rgba(&s, 30, 30)[3] > 0.9, "interior filled: {:?}", rgba(&s, 30, 30)); + assert_eq!(rgba(&s, 5, 30)[3], 0.0, "outside untouched"); + assert_eq!(rgba(&s, 30, 5)[3], 0.0, "outside untouched"); + // A soft brush feathers the edge outside the polygon; a hard one keeps a crisp edge. + let mut soft = session(60, 60); + soft.execute( + "paint.stroke", + json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "size": 40, "color": "#ff0000", "brush": {"hardness": 0.0}}), + ) + .unwrap(); + assert!(rgba(&soft, 8, 30)[3] > 0.0, "a soft edge paints outside the polygon: {:?}", rgba(&soft, 8, 30)); + // Graceful failures: fewer than three points, and the Eraser. + assert!(s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10]], "fill": true})).is_err()); + assert!(s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10], [50, 50]], "fill": true, "erase": true})).is_err()); + // One history step, like a normal stroke. + let before = s.active().unwrap().history.past_len(); + s.execute("paint.stroke", json!({"points": [[12, 12], [48, 12], [48, 48], [12, 48]], "fill": true, "color": "#0000ff"})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), before + 1, "fill is one history step"); +} + +#[test] +fn fill_is_rejected_with_clear_mode() { + // #3659 review: `"mode":"clear"` maps onto the eraser, so a clear fill would erase the region + // the outline encloses. Reject it; a selection + delete does that deliberately. + let mut s = session(60, 60); + let poly = json!([[10, 10], [50, 10], [50, 50], [10, 50]]); + assert!(s.execute("paint.stroke", json!({"points": poly.clone(), "fill": true, "mode": "clear"})).is_err()); + // `clear` without `fill` still erases along the outline (main's behaviour), unchanged. + s.execute("paint.stroke", json!({"points": poly, "fill": true, "size": 4, "color": "#ff0000"})).unwrap(); + assert!(rgba(&s, 30, 30)[3] > 0.9, "the region is filled first"); + s.execute("paint.stroke", json!({"points": [[30, 2], [30, 58]], "size": 10, "mode": "clear"})).unwrap(); + assert!(rgba(&s, 30, 30)[3] < 0.01, "a clear stroke erased what it crossed"); + assert!(rgba(&s, 12, 30)[3] > 0.9, "pixels the clear stroke missed remain"); +} + +#[test] +fn fill_soft_edge_has_no_step() { + // #3779: the feather (σ = 20 here) reaches 3σ = 60 px past the square; the fill's working + // bounds must reach as far, or the soft edge ends in a hard step where they clip it. + let mut s = session(200, 200); + s.execute( + "paint.stroke", + json!({"points": [[80, 80], [120, 80], [120, 120], [80, 120]], "fill": true, "size": 40, "color": "#ff0000", "brush": {"hardness": 0.0}}), + ) + .unwrap(); + assert!(rgba(&s, 57, 100)[3] > 0.0, "the fade continues past 1σ: {:?}", rgba(&s, 57, 100)); + for x in 0..199 { + let (a, b) = (rgba(&s, x, 100)[3], rgba(&s, x + 1, 100)[3]); + assert!((a - b).abs() < 0.03, "step between x={x} ({a}) and x={} ({b})", x + 1); + } +} + +#[test] +fn fill_paints_a_cmyk_pick_with_the_picked_inks() { + // #3779: like a plain stroke (#4344), a fill paints a colour picked as CMYK as its inks. + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + let mut s = session(16, 16); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 2], [14, 2], [14, 14], [2, 14]], "fill": true, "hardness": 1.0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let got: Vec = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect(); + assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{got:?}"); +} + +#[test] +fn fill_works_on_cmyk_and_16_and_32_bit() { + for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 30, "mode": mode, "depth": depth})).unwrap(); + s.execute("paint.stroke", json!({"points": [[10, 5], [50, 5], [50, 25], [10, 25]], "fill": true, "color": "#000000", "hardness": 1.0})).unwrap(); + assert!(rgba(&s, 30, 15)[0] < 0.2, "{mode}/{depth}: {:?}", rgba(&s, 30, 15)); + assert!(rgba(&s, 5, 15)[0] > 0.8, "{mode}/{depth} outside: {:?}", rgba(&s, 5, 15)); + } +} + #[test] fn stroke_accepts_full_points_brush_and_preset() { let mut s = session(200, 100); @@ -493,6 +580,23 @@ fn define_brush_from_selection() { assert!(rgba(&s, 30, 32)[3] > 0.9); } +#[test] +fn fill_survives_allocation_failure() { + // CodeRabbit on #9775: a fill's buffers span the region, up to the whole canvas. Their peak is + // reserved through the fallible allocator before any of them is built, so running out of + // memory is the fill's own error, leaving the pixels and history alone, never an abort. + let mut s = session(60, 60); + let pixels_before = surface(&s); + let history_before = s.active().unwrap().history.past_len(); + crate::allocation::fail_next_for_test(); + let result = s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "color": "#ff0000"})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + let err = result.expect_err("an allocation failure must be reported, not panic").to_string(); + assert!(err.contains("filling a painted region"), "the fill reserves its buffers first: {err}"); + assert_eq!(surface(&s), pixels_before, "no pixels changed"); + assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); +} + #[test] fn define_brush_from_selection_survives_allocation_failure() { // The sampled tip is one f32 buffer over the whole selection, and the selection mask is a diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 6beb64542e..a676c05d6f 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -113,6 +113,7 @@ mod view; use calc::{apply_image, calculations}; use manage::{delete_channel, duplicate_channel, move_channel, new_channel, new_spot, options, rename_channel, set_visible}; use planes::edit_mode; +pub(crate) use planes::tone; pub use planes::{ GraySurface, IsolatedCurveChannel, channel_format, color_count, filter_mask_curve_channel, isolated_curve_channel, isolated_curve_channel_for, next_alpha_name, @@ -123,8 +124,9 @@ use selection::{load_selection, quick_mask, save_selection}; use spot::{merge, merge_spot, mergeable, split}; use targeting::{SLOT_KEY, target_cmd, target_slot}; pub(crate) use targeting::{ - Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, - is_channel_target, mask_target_enabled, read_target_surface, restrict_to_color, routed, target_of, target_surface, targeted_channel, targeted_color, + Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, invert_region, + is_channel_target, is_spot_target, mask_target_enabled, read_target_surface, restrict_to_color, routed, target_of, target_surface, targeted_channel, + targeted_color, }; pub use targeting::{follows_filter_mask, follows_target}; pub(crate) use view::fix_view; diff --git a/crates/engine/src/cmd/channel_cmds/calc.rs b/crates/engine/src/cmd/channel_cmds/calc.rs index 57e71cb360..9c1d0ea6b8 100644 --- a/crates/engine/src/cmd/channel_cmds/calc.rs +++ b/crates/engine/src/cmd/channel_cmds/calc.rs @@ -1,8 +1,8 @@ use super::manage::gray_document; -use super::planes::{channel_format, next_alpha_name, plane_surface, read_plane}; -use super::refs::{Blending, bad, mask_plane, source, source_spec}; -use super::targeting::{Target, is_channel_target, target_of, target_surface}; -use super::*; +use super::planes::{channel_format, edit_mode, next_alpha_name, plane_surface, read_plane, tone}; +use super::refs::{Blending, bad, doc_index, mask_plane, source, source_spec}; +use super::targeting::{Target, is_channel_target, is_spot_target, target_of, target_surface}; +use super::{AlphaChannel, ColorMode, EngineError, Result, SelectionMode, Session, Value, json, sel}; // --------------------------------------------------------------------------------------------- // Apply Image / Calculations @@ -13,7 +13,11 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result { let opacity = p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) as f32 / 100.0; let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false); let src_spec = source_spec(p, "source", "source").unwrap_or_else(|| json!({})); - let src = source(s, &src_spec, cmd, false)?; + // A composite's planes are in its own document's mode, which may not be the target's. + let src_mode = s.documents().get(doc_index(s, &src_spec, "document")?).map(|d| edit_mode(&d.doc)).ok_or(EngineError::NoDocument)?; + // A CMYK composite reaches a channel or a layer mask as its luminosity, as Calculations reads + // it: its planes are ink, and an average of their tones would leave Black alone light gray. + let src = source(s, &src_spec, cmd, src_mode == ColorMode::Cmyk && (is_channel_target(p) || target_of(p) == Target::Mask))?; let mask = mask_plane(s, p, cmd)?; let layer = s.active().and_then(|d| d.active_layer); s.edit("Apply Image", |doc, _| { @@ -22,12 +26,22 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result { let weight = |i: usize| opacity * mask.as_ref().map_or(1.0, |m| m[i]) * selection.as_ref().map_or(1.0, |m| m[i]); let mask_target = target_of(p) == Target::Mask; if is_channel_target(p) || mask_target { - // A grayscale target gets the luminosity of a colour source (one plane). + // A grayscale target gets one plane: a CMYK composite's luminosity (above), the average of + // another composite's planes. let srcp = if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::() / src.len() as f32).collect() }; + let spot = is_spot_target(doc, p); + let ink = |v: f32| if spot { 1.0 - v } else { v }; let (surf, _) = target_surface(doc, layer, p)?; let base = read_plane(surf, area); - let out: Vec = base.iter().enumerate().map(|(i, &b)| b + (blending.apply(b, srcp[i]) - b) * weight(i)).collect(); + let out: Vec = base + .iter() + .enumerate() + .map(|(i, &b)| { + let b = ink(b); + ink(b + (blending.apply(b, srcp[i]) - b) * weight(i)) + }) + .collect(); if mask_target { // A layer mask keeps its default (reveal or hide) outside the canvas. if surf.channels() != 1 { @@ -64,9 +78,15 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result { // source-over, so applying to an empty layer copies the source in any mode. let out_a = if preserve { base_a } else { w + base_a * (1.0 - w) }; for (c, b) in px[..colors].iter_mut().enumerate() { - let top = src.get(c).unwrap_or(&src[0])[i]; - let blended = top + (blending.apply(*b, top) - top) * base_a; - *b = if preserve { *b + (blended - *b) * w } else { (w * blended + base_a * (1.0 - w) * *b) / out_a }; + // Blended as tone, as Photoshop does: a CMYK layer's ink turns into tone and back + // (Multiply darkens, so it adds ink). One plane (a channel, a mask) is a tone already. + // A CMYK composite onto another mode darkens each tone by its Black, so Black + // alone stays black. + let k = if src_mode == ColorMode::Cmyk && sf.mode != ColorMode::Cmyk { src.get(3).map_or(1.0, |pl| tone(src_mode, pl[i])) } else { 1.0 }; + let top = if src.len() > 1 { tone(src_mode, src.get(c).unwrap_or(&src[0])[i]) * k } else { src[0][i] }; + let base = tone(sf.mode, *b); + let blended = top + (blending.apply(base, top) - top) * base_a; + *b = tone(sf.mode, if preserve { base + (blended - base) * w } else { (w * blended + base_a * (1.0 - w) * base) / out_a }); } if sf.alpha { px[colors] = out_a; diff --git a/crates/engine/src/cmd/channel_cmds/manage.rs b/crates/engine/src/cmd/channel_cmds/manage.rs index 0aaa045cc7..9c81381bf4 100644 --- a/crates/engine/src/cmd/channel_cmds/manage.rs +++ b/crates/engine/src/cmd/channel_cmds/manage.rs @@ -3,7 +3,9 @@ use super::refs::{ChanRef, alpha_index, bad, doc_index, gray_plane, parse_ref}; use super::selection::{uniform, unselected_surface}; use super::targeting::target; use super::view::{ChannelTarget, QuickMaskOptions, color_names, composite_name, fix_view, touch}; -use super::*; +use super::{ + AlphaChannel, Color, ColorIndicates, ColorMode, DocState, Document, EngineError, Layer, PixelFormat, Result, Session, Surface, Value, json, list, sel, +}; // --------------------------------------------------------------------------------------------- // Channel management diff --git a/crates/engine/src/cmd/channel_cmds/planes.rs b/crates/engine/src/cmd/channel_cmds/planes.rs index f434a2fa7b..e027dbb3df 100644 --- a/crates/engine/src/cmd/channel_cmds/planes.rs +++ b/crates/engine/src/cmd/channel_cmds/planes.rs @@ -1,5 +1,5 @@ use super::view::{ChannelTarget, color_names}; -use super::*; +use super::{ColorMode, DocState, Document, PixelFormat, Rect, Surface}; /// The Curves dialog's Channel popup lists only this channel when the Channels panel has a /// single non-composite target (a colour component, layer mask, saved channel or Quick Mask). @@ -133,6 +133,13 @@ pub(super) fn materialize(s: &mut Surface, r: Rect) { s.write_region(r, &v); } +/// A colour channel sample as the Channels panel shows it: CMYK stores ink (1 = full ink) but a +/// CMYK channel reads as its tone (white = no ink), as in Photoshop. Its own inverse, so it also +/// turns a tone back into ink. Other modes are unchanged. +pub(crate) fn tone(mode: ColorMode, v: f32) -> f32 { + if mode == ColorMode::Cmyk { 1.0 - v } else { v } +} + pub(super) fn luminosity(rgba: [f32; 4]) -> f32 { 0.299 * rgba[0] + 0.587 * rgba[1] + 0.114 * rgba[2] } diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 876fa0b49b..58708475da 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -1,6 +1,6 @@ -use super::planes::{color_count, edit_mode, luminosity, read_plane}; +use super::planes::{color_count, edit_mode, luminosity, read_plane, tone}; use super::view::{color_names, composite_name}; -use super::*; +use super::{BlendMode, Document, EngineError, LayerContent, LayerId, Result, Session, Value, blend_channel, from_rgba, sel, to_rgba}; // --------------------------------------------------------------------------------------------- // Channel references @@ -127,11 +127,19 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r let (px, n) = native_pixels(doc, layer)?; let planes: Vec> = (0..colors).map(|c| px.chunks_exact(n).map(|p| p[c]).collect()).collect(); match r { - ChanRef::Color(k) => vec![planes.get(k).cloned().ok_or_else(|| EngineError::Other(format!("no colour channel {k}")))?], + ChanRef::Color(k) => { + let plane = planes.get(k).ok_or_else(|| EngineError::Other(format!("no colour channel {k}")))?; + vec![plane.iter().map(|&v| tone(edit_mode(doc), v)).collect()] + } _ => planes, } } - ChanRef::Alpha(i) => vec![read_plane(&doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i}")))?.surface, area)], + ChanRef::Alpha(i) => { + let ch = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i}")))?; + let plane = read_plane(&ch.surface, area); + // A spot channel stores ink (1 = solid) but reads as its tone, as in Photoshop. + vec![if ch.spot.is_some() { plane.into_iter().map(|v| 1.0 - v).collect() } else { plane }] + } ChanRef::QuickMask => vec![read_plane(&doc.quick_mask.as_ref().ok_or_else(|| EngineError::Other("not in Quick Mask mode".into()))?.surface, area)], ChanRef::Selection => vec![sel::mask_from_surface(doc.selection.as_ref(), area)], ChanRef::Transparency => { diff --git a/crates/engine/src/cmd/channel_cmds/selection.rs b/crates/engine/src/cmd/channel_cmds/selection.rs index 9a24b9fe56..a49c551d2f 100644 --- a/crates/engine/src/cmd/channel_cmds/selection.rs +++ b/crates/engine/src/cmd/channel_cmds/selection.rs @@ -1,7 +1,7 @@ use super::planes::{channel_format, next_alpha_name, plane_surface, read_plane}; use super::refs::{ChanRef, bad, doc_index, parse_ref, source}; use super::view::{ChannelTarget, QuickMaskOptions}; -use super::*; +use super::{AlphaChannel, ColorIndicates, Document, EngineError, PixelFormat, Rect, Result, SelectionMode, Session, Surface, Value, json, operation, sel}; pub(super) fn save_selection(s: &mut Session, p: &Value) -> Result { let cmd = "select.saveSelection"; diff --git a/crates/engine/src/cmd/channel_cmds/spot.rs b/crates/engine/src/cmd/channel_cmds/spot.rs index a3fefee98a..773089faad 100644 --- a/crates/engine/src/cmd/channel_cmds/spot.rs +++ b/crates/engine/src/cmd/channel_cmds/spot.rs @@ -1,8 +1,8 @@ use super::manage::{fix_view_now, gray_document}; -use super::planes::{channel_format, edit_mode, plane_surface, read_plane}; +use super::planes::{channel_format, edit_mode, plane_surface, read_plane, tone}; use super::refs::{ChanRef, alpha_index, bad, gray_plane, native_pixels}; use super::view::{ChannelTarget, ChannelView, color_names}; -use super::*; +use super::{AlphaChannel, ColorMode, DocState, Document, EngineError, Layer, Result, Session, Value, from_rgba, json}; /// Merge Spot Channel: flattens the image and prints the spot ink into the colour channels. pub(super) fn merge_spot(s: &mut Session, p: &Value) -> Result { @@ -66,7 +66,7 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result { let short = |name: &str| name.chars().next().map(|c| c.to_ascii_uppercase().to_string()).unwrap_or_default(); let mut docs: Vec = Vec::new(); for (c, name) in names.iter().enumerate() { - let plane: Vec = px.chunks_exact(n).map(|p| p[c]).collect(); + let plane: Vec = px.chunks_exact(n).map(|p| tone(mode, p[c])).collect(); docs.push(gray_document(&format!("{}_{}", doc.name, short(name)), &doc, &plane)); } for ch in &doc.channels { @@ -133,7 +133,7 @@ pub(super) fn merge(s: &mut Session, p: &Value) -> Result { let mut data = Vec::with_capacity(npx * fmt.channels()); for i in 0..npx { for pl in &planes[..cc] { - data.push(pl[i].clamp(0.0, 1.0)); + data.push(tone(mode, pl[i]).clamp(0.0, 1.0)); } data.push(1.0); } diff --git a/crates/engine/src/cmd/channel_cmds/targeting.rs b/crates/engine/src/cmd/channel_cmds/targeting.rs index ce131cd43a..34e1f269fb 100644 --- a/crates/engine/src/cmd/channel_cmds/targeting.rs +++ b/crates/engine/src/cmd/channel_cmds/targeting.rs @@ -2,7 +2,7 @@ use super::manage::fix_view_now; use super::planes::{color_count, materialize}; use super::refs::{ChanRef, bad, parse_ref}; use super::view::ChannelTarget; -use super::*; +use super::{ColorMode, Document, EngineError, Layer, LayerId, Rect, Result, Session, Surface, TileCoord, Value, from_rgba, json, list}; // --------------------------------------------------------------------------------------------- // Targets for pixel commands @@ -37,6 +37,19 @@ pub(crate) fn is_channel_target(p: &Value) -> bool { matches!(target_of(p), Target::Alpha(_) | Target::QuickMask) } +/// The target is a spot channel: it stores ink but takes tone, as it reads. +pub(crate) fn is_spot_target(doc: &Document, p: &Value) -> bool { + matches!(target_of(p), Target::Alpha(i) if doc.channels.get(i).is_some_and(|c| c.spot.is_some())) +} + +/// Inverts `r` of a channel surface (ink to tone and back). +pub(crate) fn invert_region(s: &mut Surface, r: Rect) -> Result<()> { + let mut v = crate::allocation::read_region(s, r, "inverting a channel")?; + v.iter_mut().for_each(|x| *x = 1.0 - *x); + s.try_write_region(r, &v)?; + Ok(()) +} + /// The surface a pixel command writes to and whether the layer's transparency is locked. pub(crate) fn target_surface<'a>(doc: &'a mut Document, layer: Option, p: &Value) -> Result<(&'a mut Surface, bool)> { match target_of(p) { diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index 2ae1bd94b0..079b2e4c86 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -120,6 +120,38 @@ fn load_transparency_mask_and_composite() { assert_eq!(sel_at(&s, 10, 5), 1.0); } +/// A white CMYK document (no ink anywhere) whose left half carries some cyan ink. +fn cmyk_session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap(); + rect(&mut s, 0, 0, 20, 20, "replace"); + s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s +} + +#[test] +fn cmyk_channels_duplicate_and_load_as_tone_not_ink() { + // #5787: a CMYK channel is its tone, as the Channels panel draws it: white where there is no ink. + // (The merged image goes through RGB, so inked pixels may drift a few levels.) + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let ink = px(&s, 5, 5)[0]; + assert!(ink > 0.5 && px(&s, 30, 5)[0] == 0.0, "depth {depth}: cyan ink on the left only, {ink}"); + s.execute("channel.duplicate", json!({"channel": "cyan"})).unwrap(); + assert_eq!(doc(&s).channels[0].name, "Cyan copy"); + assert!((chan(&s, 0, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: no ink duplicates as white"); + assert!((chan(&s, 0, 5, 5) - (1.0 - ink)).abs() < 0.02, "depth {depth}: ink duplicates dark"); + s.execute("select.loadSelection", json!({"channel": "cyan"})).unwrap(); + assert!((sel_at(&s, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: no ink is selected"); + assert!((sel_at(&s, 5, 5) - (1.0 - ink)).abs() < 0.02, "depth {depth}"); + // Calculations read the same tone. + let r = s.execute("image.calculations", json!({"source1": {"channel": "cyan"}, "source2": {"channel": "cyan"}, "blending": "normal"})).unwrap(); + let i = r["channel"].as_u64().unwrap() as usize; + assert!((chan(&s, i, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}"); + } +} + #[test] fn channel_management_and_view_state() { let mut s = session(); @@ -627,6 +659,166 @@ fn apply_image_into_alpha_channel() { assert!(chan(&s, 0, 3, 3) < 0.01); } +#[test] +fn cmyk_apply_image_of_a_colour_channel_keeps_ink_and_tone_apart() { + // #5787: the Cyan channel is read as tone and written back as ink, so applying it onto its own + // CMYK layer leaves cyan as it was, and into an alpha channel it lands as tone. (The merged + // image goes through RGB, so a few levels of drift are allowed.) + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let before = (px(&s, 5, 5), px(&s, 30, 5)); + s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal"})).unwrap(); + let after = px(&s, 5, 5); + assert!((after[0] - before.0[0]).abs() < 0.02, "depth {depth}: cyan unchanged, {after:?} vs {:?}", before.0); + assert!(px(&s, 30, 5).iter().zip(&before.1).all(|(a, b)| (a - b).abs() < 1e-3), "depth {depth}: no ink stays no ink"); + s.undo(); + s.execute("image.applyImage", json!({"blending": "normal"})).unwrap(); + assert!(px(&s, 5, 5).iter().zip(&before.0).all(|(a, b)| (a - b).abs() < 0.02), "depth {depth}: CMYK onto itself is a no-op"); + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal"})).unwrap(); + assert!((chan(&s, 0, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: tone into an alpha channel"); + } +} + +#[test] +fn cmyk_apply_image_blends_as_tone() { + // Photoshop blends a CMYK layer as tone: Multiply (the default) with a white source changes + // nothing, and Multiply with Cyan multiplies every channel's tone by Cyan's, so it adds ink. + // A spot channel reads as tone too: its solid ink applies as full ink and isn't selected. + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + s.execute("channel.new", json!({"fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + s.execute("image.applyImage", json!({"source": {"channel": 0}})).unwrap(); + assert!(near(&px(&s, 5, 5), &left), "depth {depth}: white multiplies to no change, {:?} vs {left:?}", px(&s, 5, 5)); + s.undo(); + s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "multiply"})).unwrap(); + let want: Vec = left[..4].iter().map(|&ink| 1.0 - (1.0 - ink) * (1.0 - left[0])).chain([1.0]).collect(); + assert!(near(&px(&s, 5, 5), &want), "depth {depth}: {:?} vs {want:?}", px(&s, 5, 5)); + assert!(near(&px(&s, 30, 5), &right), "depth {depth}: no ink stays no ink"); + s.undo(); + rect(&mut s, 0, 0, 20, 20, "replace"); + let r = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap(); + let spot = r["channel"].as_u64().unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + s.execute("image.applyImage", json!({"source": {"channel": spot}, "blending": "normal"})).unwrap(); + assert!(near(&px(&s, 5, 5), &[1.0; 5]) && near(&px(&s, 30, 5), &right), "depth {depth}: spot ink applies as ink, {:?}", px(&s, 5, 5)); + s.execute("select.loadSelection", json!({"channel": spot})).unwrap(); + assert!(sel_at(&s, 5, 5) < 1e-3 && (sel_at(&s, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: the spot's unprinted area loads"); + } +} + +#[test] +fn apply_image_onto_a_spot_channel_blends_as_tone() { + let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + // #5787: a spot channel stores ink but reads and takes tone, so the spot applied onto itself + // changes nothing (Normal or Multiply), and Cyan applied onto it gives it Cyan's ink. + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let cyan = px(&s, 5, 5)[0]; + rect(&mut s, 0, 0, 10, 20, "replace"); + let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let ink = |s: &Session| [5, 15, 30].map(|x| chan(s, spot as usize, x, 5)); + let before = ink(&s); + assert!(near(&before, &[1.0, 0.0, 0.0]), "depth {depth}: {before:?}"); + for blending in ["normal", "multiply"] { + s.execute("image.applyImage", json!({"source": {"channel": spot}, "blending": blending, "target": {"channel": spot}})).unwrap(); + assert!(near(&ink(&s), &before), "depth {depth}: {blending} onto itself, {:?}", ink(&s)); + } + s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal", "target": {"channel": spot}})).unwrap(); + assert!(near(&ink(&s), &[cyan, cyan, 0.0]), "depth {depth}: Cyan's ink, {:?}", ink(&s)); + } +} + +#[test] +fn apply_image_from_an_rgb_document_onto_cmyk_reads_it_in_its_own_mode() { + let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + // #5787: an RGB composite is tone in RGB terms: white multiplied (the default) onto a CMYK + // layer changes nothing. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "background": "white"})).unwrap(); + rect(&mut s, 0, 0, 20, 20, "replace"); + s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let before = (px(&s, 5, 5), px(&s, 30, 5)); + s.execute("image.applyImage", json!({"source": {"document": 0}})).unwrap(); + assert!(near(&px(&s, 5, 5), &before.0) && near(&px(&s, 30, 5), &before.1), "{:?} vs {before:?}", (px(&s, 5, 5), px(&s, 30, 5))); +} + +#[test] +fn apply_image_of_a_cmyk_composite_onto_a_spot_channel_blends_as_tone() { + // #5787: the CMYK composite stores ink, so its white has none: applied in Normal onto an empty + // spot channel, the spot stays empty on the white and takes ink on the Cyan. + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap() as usize; + s.execute("image.applyImage", json!({"blending": "normal", "target": {"channel": spot}})).unwrap(); + let (cyan, white) = (chan(&s, spot, 5, 5), chan(&s, spot, 30, 5)); + assert!(white < 0.01 && cyan > 0.1 && cyan < 0.9, "depth {depth}: cyan {cyan}, white {white}"); + } +} + +#[test] +fn apply_image_of_a_cmyk_composite_onto_rgb_keeps_black() { + // #5787: Black alone (no C, M or Y) applied from a CMYK document onto RGB stays black, and + // the CMYK white stays white (the merged image goes through RGB, so a few levels of drift). + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "background": "white"})).unwrap(); + { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + let surf = std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().surface_mut().unwrap(); + let n = surf.format().channels(); + let mut k = vec![0.0; 20 * 20 * n]; + k.chunks_exact_mut(n).for_each(|p| p[3] = 1.0); + surf.write_region(Rect::new(0, 0, 20, 20), &k); + } + s.execute("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + s.execute("image.applyImage", json!({"source": {"document": 0}, "blending": "normal"})).unwrap(); + let (black, white) = (px(&s, 5, 5), px(&s, 30, 5)); + assert!(black[..3].iter().all(|&v| v < 0.05), "{black:?}"); + assert!(white[..3].iter().all(|&v| v > 0.95), "{white:?}"); +} + +#[test] +fn apply_image_of_a_cmyk_composite_onto_an_alpha_channel_takes_its_luminosity() { + // #5787: a CMYK composite reaches a channel as its luminosity, as Calculations reads it, so + // Black alone stays dark and white stays white. + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap(); + { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + let surf = std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().surface_mut().unwrap(); + let n = surf.format().channels(); + let mut k = vec![1.0; 20 * 20 * n]; + k.chunks_exact_mut(n).for_each(|p| p[..3].fill(0.0)); + surf.write_region(Rect::new(0, 0, 20, 20), &k); + } + let a = s.execute("channel.new", json!({})).unwrap()["channel"].as_u64().unwrap() as usize; + s.execute("image.applyImage", json!({"blending": "normal", "target": {"channel": a}})).unwrap(); + let b = s.execute("image.calculations", json!({"source1": {}, "source2": {}, "blending": "normal"})).unwrap()["channel"].as_u64().unwrap() as usize; + let (black, white) = (chan(&s, a, 5, 5), chan(&s, a, 30, 5)); + assert!(black < 0.25 && (white - 1.0).abs() < 1e-3, "depth {depth}: black {black}, white {white}"); + assert!((black - chan(&s, b, 5, 5)).abs() < 1e-3, "depth {depth}: as Calculations reads it"); + // A targeted layer mask is a grayscale target too: it takes the same luminosity. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + s.execute("image.applyImage", json!({"blending": "normal"})).unwrap(); + let d = s.active().unwrap(); + let m = &d.doc.layer(d.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface; + let (mb, mw) = (m.sample_channel(5, 5, 0), m.sample_channel(30, 5, 0)); + assert!((mb - black).abs() < 1e-3 && (mw - 1.0).abs() < 1e-3, "depth {depth}: mask black {mb}, white {mw}"); + } +} + #[test] fn apply_image_into_targeted_layer_mask_leaves_the_pixels() { let mut s = session(); @@ -706,6 +898,30 @@ fn split_and_merge_channels() { assert!(s.execute("channel.merge", json!({"mode": "cmyk"})).is_err()); } +#[test] +fn cmyk_split_and_merge_use_tone() { + // #5787: Split Channels of a white CMYK image gives white grayscale documents, and merging + // them back gives the same CMYK pixels (within the few levels the merged image's trip + // through RGB costs). + for depth in [8, 16] { + let mut s = cmyk_session(depth); + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + s.execute("channel.split", json!({})).unwrap(); + assert_eq!(s.documents().len(), 4); + let v = |s: &Session, i: usize, x: i32| s.documents()[i].doc.layers[0].surface().unwrap().pixel(x, 5)[0]; + for i in 0..4 { + assert!((v(&s, i, 30) - 1.0).abs() < 1e-3, "depth {depth}: channel {i} of white is white"); + } + assert!((v(&s, 0, 5) - (1.0 - left[0])).abs() < 0.02, "depth {depth}: cyan ink splits dark"); + s.set_active(0); + s.execute("channel.merge", json!({"mode": "cmyk", "documents": [0, 1, 2, 3]})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Cmyk); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02); + assert!(near(&px(&s, 5, 5), &left), "depth {depth}: round trip {:?} vs {left:?}", px(&s, 5, 5)); + assert!(near(&px(&s, 30, 5), &right), "depth {depth}: white stays white {:?}", px(&s, 30, 5)); + } +} + #[test] fn merge_rejects_a_repeated_source_and_keeps_unrelated_documents() { // #934: `documents: [0, 0, 1]` passed validation, then the close loop ran `close(1)`, diff --git a/crates/engine/src/cmd/channel_cmds/view.rs b/crates/engine/src/cmd/channel_cmds/view.rs index 12afd4e9ce..e93be8ff0a 100644 --- a/crates/engine/src/cmd/channel_cmds/view.rs +++ b/crates/engine/src/cmd/channel_cmds/view.rs @@ -1,5 +1,5 @@ use super::planes::color_count; -use super::*; +use super::{AlphaChannel, Color, ColorIndicates, ColorMode, DocState, Document, Serialize}; // --------------------------------------------------------------------------------------------- // View state diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index eb77590999..71800c63c6 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -403,7 +403,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable); *active = Some(id); doc.selection = None; Ok(id) @@ -445,9 +445,13 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< let id = s.edit("Paste", |doc, active| { doc.patterns.extend(patterns); doc.metadata.psd_global_blocks.extend(linked); + // The first pasted layer goes where a new layer would; the rest stack above it. + let mut place = crate::layer_place::Place::AutoEditable; for l in copies.layers { - *active = Some(doc.insert_above(*active, l)); + *active = Some(crate::layer_place::insert_new(doc, *active, l, place)); + place = crate::layer_place::Place::Above; } + crate::cmd::layer_multi_cmds::check_group_depth(doc, "Paste")?; doc.selection = None; active.ok_or(EngineError::Other("nothing to paste".into())) })?; @@ -506,8 +510,16 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: let src = shifted(&clip.surface, dx, dy); let area = src.content_bounds().intersect(&canvas); s.edit(label, |doc, _| { + // A spot channel stores ink: the paste goes over its tone, then back to ink (#5787). + let spot = crate::channel_cmds::is_spot_target(doc, p); let (surf, _) = crate::channel_cmds::target_surface(doc, layer, p)?; + if spot { + crate::channel_cmds::invert_region(surf, area)?; + } crate::fill_cmds::composite_over(surf, &src, area, limit)?; + if spot { + crate::channel_cmds::invert_region(surf, area)?; + } surf.prune(); doc.selection = None; Ok(()) @@ -579,7 +591,7 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { } let mut l = Layer::raster(doc.next_layer_name("Layer"), clip.surface.format()); *crate::pixels_mut(&mut l)? = clip.surface; - let nid = doc.insert_above(Some(id), l); + let nid = crate::layer_place::insert_new(doc, Some(id), l, crate::layer_place::Place::Above); *active = Some(nid); // Photoshop deselects: a following Free Transform moves the whole new layer (#1096). doc.selection = None; @@ -761,11 +773,8 @@ fn transform_again_copy(s: &mut Session) -> Result { Err(e) => { // The copy (e.g. of a layer whose position is locked) couldn't be transformed: take // it back, leaving nothing to redo and no Duplicate step to record. - s.undo(); + s.rollback(); s.journal.truncate(journal); - if let Some(st) = s.active_mut() { - st.history.clear_redo(); - } Err(e) } } diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index e632d9fb71..ec9a9fa8b7 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -1138,6 +1138,31 @@ fn transform_again_on_a_copy_steps_and_repeats() { assert!(!s.active().unwrap().history.can_redo(), "nothing to redo"); } +/// History Options › Allow Non-Linear History: the Duplicate step of a refused Transform +/// Again on a copy moved the later states in front of it; taking it back must restore the +/// document before the command and the History list as it was, not land on a later state. +#[test] +fn a_refused_transform_again_on_a_copy_rolls_back_exactly_in_non_linear_history() { + let mut s = session(); + s.execute("history.options", json!({"allowNonLinearHistory": true})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"layer": id.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.setProps", json!({"layer": id.0, "opacity": 0.5})).unwrap(); + s.execute("layer.setProps", json!({"layer": id.0, "opacity": 0.25})).unwrap(); + s.undo(); + s.undo(); + let st = s.active().unwrap(); + let (doc, entries) = (st.doc.clone(), st.history.entries()); + let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + assert_eq!(redo.len(), 2); + assert!(s.execute("edit.transform.againCopy", json!({})).unwrap_err().to_string().contains("locked")); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document before the command"); + assert_eq!(st.history.entries(), entries); + assert_eq!(st.history.redo_labels().map(str::to_string).collect::>(), redo); +} + #[test] fn toggle_last_state_and_transform_again() { let mut s = session(); diff --git a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs index 0fe3cedd3f..928f315bdf 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Document, EngineError, Layer, LayerId, PixelFormat, Rect, Result, Session, Surface, Value, bad, bool_or, from_rgba_into, int, json, pixel_layer, + pixel_layer_id, str_or, to_rgba, writable_surface, +}; // ------------------------------------------------------------------ Content-Aware Fill @@ -503,7 +506,7 @@ fn apply_content_aware_fill(s: &mut Session, label: &str, id: LayerId, output: & s.write_region(window, &px); s.prune(); } - let nid = doc.insert_above(Some(id), l); + let nid = crate::layer_place::insert_new(doc, Some(id), l, crate::layer_place::Place::Above); *active = Some(nid); return Ok(nid); } diff --git a/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs b/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs index 998f30821a..e0d05742df 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/content_aware_scale.rs @@ -1,5 +1,7 @@ use super::content_aware::channel_mask; -use super::*; +use super::{ + Document, EngineError, LayerId, PixelFormat, Rect, Result, Session, Surface, Value, bad, bool_or, f32_or, json, pixel_layer, to_rgba, writable_surface, +}; // ------------------------------------------------------------------ Content-Aware Scale diff --git a/crates/engine/src/cmd/edit_menu_cmds/fade.rs b/crates/engine/src/cmd/edit_menu_cmds/fade.rs index 5ab5e4455b..14f9b8365d 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/fade.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/fade.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, + surface_of_mut, to_rgba, +}; // ------------------------------------------------------------------ Fade diff --git a/crates/engine/src/cmd/edit_menu_cmds/find_replace.rs b/crates/engine/src/cmd/edit_menu_cmds/find_replace.rs index 91f16950f1..4b9e3ace73 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/find_replace.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/find_replace.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EngineError, LayerContent, LayerId, Result, Session, Value, bad, bool_or, json, layer_param, str_or}; // ------------------------------------------------------------------ Find and Replace Text diff --git a/crates/engine/src/cmd/edit_menu_cmds/presets.rs b/crates/engine/src/cmd/edit_menu_cmds/presets.rs index 8f26426287..abc3a61172 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/presets.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/presets.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CustomShape, Deserialize, Document, EngineError, LayerContent, LayerId, Path, Result, Serialize, Session, Value, bad, json, str_or}; // ------------------------------------------------------------------ Define Brush / Custom Shape diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index a422e82bfb..ae0a090a40 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Result, Session, Value, json}; // ------------------------------------------------------------------ Purge diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs index c609b9e0ba..f7b4f00737 100644 --- a/crates/engine/src/cmd/enabled.rs +++ b/crates/engine/src/cmd/enabled.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EngineError, LayerContent, LayerId, Result, Session, Value}; pub(crate) fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index 4a3190ec72..cdefb64cba 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -574,7 +574,7 @@ fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result { return crate::layer_multi_cmds::set_visible_selected(s, visible); } let id = layer_param(s, p)?; - s.edit(if visible { "Show Layer" } else { "Hide Layer" }, |doc, _| { + s.edit_visibility(if visible { "Show Layer" } else { "Hide Layer" }, |doc, _| { doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.visible = visible; Ok(()) })?; @@ -640,7 +640,7 @@ fn show_only(s: &mut Session, p: &Value) -> Result { let rows = walk.iter().map(|(p, _, l)| (p.clone(), before.as_ref().is_none_or(|b| b.get(&l.id).copied().unwrap_or(l.visible)))).collect(); ("Show Layers", rows, None) }; - s.edit(label, |doc, _| { + s.edit_visibility(label, |doc, _| { for (p, visible) in &rows { if let Some(l) = doc.layer_at_mut(p) { l.visible = *visible; diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 24685387f0..0506b9dded 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -2,7 +2,7 @@ use super::open::import_at_raw_ppi; use super::paths::{extension, file_name, join, list_images, read_file, stem}; use super::processing::parse_steps; use super::save::{SaveOpts, save_doc}; -use super::*; +use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param}; /// Inputs of a batch command: `"input"` is a folder or an array of files. pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { @@ -138,12 +138,16 @@ pub(crate) fn process_tree( /// The extension a batch output takes with Format "Same": the input's own, except for formats /// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on -/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input -/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG. +/// every file), GIMP files (`gz` is the last extension of `.xcf.gz`), Photoshop templates and the +/// codec formats without a writer in this build. Those are saved as PSD, the format Photoshop's +/// Save As offers for a raw. An input whose file name has no extension (a dot in a folder name +/// doesn't count) is saved as PNG. pub(super) fn same_format(path: &str) -> String { - const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"]; + const IMPORT_ONLY: &[&str] = + &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm", "xcf", "xcfgz", "gz", "psdt"]; let ext = extension(path).unwrap_or_else(|| "png".into()); - if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext } + let no_writer = photocraft_codecs::from_extension(&ext).is_some_and(|f| !f.caps().write); + if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) || no_writer { "psd".into() } else { ext } } #[cfg(test)] @@ -155,6 +159,17 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd"); assert_eq!(same_format("/in/logo.svg"), "psd"); assert_eq!(same_format("/in/scan.cin"), "psd"); + // Formats PhotoCraft opens and does not write are saved as PSD. + for name in ["/in/scan.dcm", "/in/scan.DICOM", "/in/scan.dc3", "/in/page.xcf", "/in/page.xcf.gz", "/in/page.xcfgz", "/in/card.psdt"] { + assert_eq!(same_format(name), "psd", "{name}"); + } + for f in photocraft_codecs::Format::ALL.into_iter().filter(|f| f.caps().read && !f.caps().write) { + for ext in f.extensions() { + assert_eq!(same_format(&format!("/in/a.{ext}")), "psd", "{f:?} .{ext}"); + } + } + assert_eq!(same_format("/in/a.pam"), "pam"); + assert_eq!(same_format("/in/a.dds"), "dds"); // No extension in the file name: PNG, not the path after a dot in a folder name. assert_eq!(same_format("/in/v1.2/scan"), "png"); assert_eq!(same_format("C:\\in.d\\scan"), "png"); diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index ef6ad0e056..80213dba16 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -1,6 +1,6 @@ use super::paths::{join, sanitize, stem, write_file}; use super::save::{SaveOpts, encode}; -use super::*; +use super::{ColorMode, Document, EngineError, Layer, LayerContent, LayerId, Rect, Result, Session, Surface, Value, from_rgba_into, json, str_param}; // ---------- Export ---------- diff --git a/crates/engine/src/cmd/file_cmds/info.rs b/crates/engine/src/cmd/file_cmds/info.rs index e038d0c097..3f9436349d 100644 --- a/crates/engine/src/cmd/file_cmds/info.rs +++ b/crates/engine/src/cmd/file_cmds/info.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Result, Session, Value, json}; // ---------- File Info (XMP) ---------- diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index d95174ad9d..efc44e880f 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -1,5 +1,8 @@ use super::paths::{file_name, read_file, stem, template_name}; -use super::*; +use super::{ + Affine, Arc, Document, EngineError, Layer, LayerContent, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, flattened_checked, json, + resize_surface, str_param, translate_surface, +}; pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { import_result(name, bytes).map(|r| r.document) @@ -202,7 +205,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } -/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's -/// gzipped `.xcf.gz`, whose last extension alone says nothing. +/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, an extension +/// of a format `photocraft-codecs` reads in this build, or GIMP's gzipped `.xcf.gz`, whose last +/// extension alone says nothing. #[cfg(not(target_arch = "wasm32"))] pub(super) fn openable_name(path: &str) -> bool { let lower = file_name(path).to_ascii_lowercase(); - lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext)) + lower.ends_with(".xcf.gz") + || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext) || photocraft_codecs::from_extension(ext).is_some_and(|f| f.caps().read)) } -/// Extensions the batch commands pick up from a folder. +/// Extensions the batch commands pick up from a folder besides the codec formats: the document +/// and vector formats `photocraft_io::import` reads outside `photocraft-codecs`, and the camera +/// raws whose usual files `photocraft_raw::decode` develops. Not CR3, ORF, RAF, RW2, SRF or SR2: +/// CR3, compressed ORF and RAF and RW2 raw formats other than 3, 4 and 5 aren't decoded yet, and +/// import falls back to the embedded JPEG preview with only a warning, which a batch must not +/// process silently. #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ - "pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", - "ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", - "afphoto", "afpub", + "pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "psdt", "pcraft", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", "dng", "cr2", "nef", + "nrw", "arw", "pef", ]; pub(crate) fn file_name(path: &str) -> String { diff --git a/crates/engine/src/cmd/file_cmds/processing.rs b/crates/engine/src/cmd/file_cmds/processing.rs index b9718aab70..3388f4d7a9 100644 --- a/crates/engine/src/cmd/file_cmds/processing.rs +++ b/crates/engine/src/cmd/file_cmds/processing.rs @@ -2,7 +2,10 @@ use super::batch_run::{batch_inputs_tree, process_tree}; use super::open::import_opened; use super::paths::{file_name, list_images, read_file, stem}; use super::save::SaveOpts; -use super::*; +use super::{ + ColorMode, Document, EngineError, Layer, LayerContent, LayerId, Result, Session, Surface, Value, buffer_surface, flattened_checked, json, size_param, + str_param, +}; // ---------- Automate ---------- diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 25104a7b13..fff62dc7f4 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -1,5 +1,5 @@ use super::paths::{same_file, write_file}; -use super::*; +use super::{Document, EngineError, Layer, LayerContent, Result, Session, Value, f64_param, flattened_checked, json, str_param}; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone /// Background layer). The UI asks before such a save when the "ask before saving layered TIFF" diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 2a23dde9f1..7eb770d844 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1835,6 +1835,50 @@ fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() { assert!(!super::openable_name("notes.txt")); } +/// A folder batch picks up every extension that the codecs open: Cineon, DICOM, Amiga IFF, PCX, +/// XPM, DDS, the Netpbm family, and Photoshop templates. Not the raws whose usual files open only +/// as the embedded JPEG preview (CR3, compressed ORF and RAF, RW2 other than raw formats 3, 4 and +/// 5): a batch would process the thumbnail without a word. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn folder_listing_takes_every_format_photocraft_opens() { + let codecs = ["a.cin", "a.dcm", "a.dicom", "a.dc3", "a.iff", "a.lbm", "a.ilbm", "a.pcx", "a.xpm", "a.dds", "a.pbm", "a.pam", "a.pfm", "a.psdt", "A.PCX"]; + let raws = ["a.dng", "IMG_0001.CR2", "a.nef", "a.nrw", "a.arw", "a.pef"]; + for name in codecs.into_iter().chain(raws) { + assert!(super::openable_name(name), "{name}"); + } + for f in photocraft_codecs::Format::ALL.into_iter().filter(|f| f.caps().read) { + for ext in f.extensions() { + assert!(super::openable_name(&format!("a.{ext}")), "{f:?} .{ext}"); + } + } + for name in ["a.cr3", "IMG_0001.CR3", "a.orf", "a.raf", "a.rw2", "a.srf", "a.sr2"] { + assert!(!super::openable_name(name), "{name}"); + } + // PhotoCraft does not read AVIF. + assert!(!super::openable_name("a.avif")); +} + +/// A batch over a folder processes PAM, PFM and DDS files instead of skipping them. +#[test] +fn batch_folder_processes_netpbm_and_dds_files() { + let dir = tmp("batch-folder-formats"); + let src = join(&dir, "in"); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ff0000"})).unwrap(); + for name in ["a.pam", "b.pfm", "c.dds"] { + save_doc(doc(&s), &join(&src, name), None).unwrap(); + } + std::fs::write(join(&src, "notes.txt"), b"not an image").unwrap(); + let out = join(&dir, "out"); + let r = Session::new().execute("file.automate.batch", json!({"steps": [], "input": src, "output": out, "format": "png"})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|f| file_name(f.as_str().unwrap())).collect(); + files.sort(); + assert_eq!(files, ["a.png", "b.png", "c.png"], "{r}"); +} + /// The Compression tag (259) of a TIFF's first directory. fn tiff_compression_tag(b: &[u8]) -> Option { let le = b.get(..2)? == b"II"; diff --git a/crates/engine/src/cmd/font_library_cmds.rs b/crates/engine/src/cmd/font_library_cmds.rs new file mode 100644 index 0000000000..7b3fb271ae --- /dev/null +++ b/crates/engine/src/cmd/font_library_cmds.rs @@ -0,0 +1,105 @@ +//! The font menu's favourite font families: the star beside each font, kept until un-starred. They +//! live in the preferences, so they survive a restart, and are plain family names: a favourite +//! whose font is uninstalled is kept, though the menu lists installed fonts only, and shows it +//! again once the font is back (`type.font.favorite` with `"on": false` removes it). (The +//! recently used fonts are the shell's, `PhotocraftApp::note_recent_font`.) + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// The most favourites kept (a hand-edited preferences file can't grow the menu without bound). +pub const MAX_FAVORITE_FONTS: usize = 1000; +/// The longest family name accepted, in characters. +const MAX_FAMILY_CHARS: usize = 256; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +/// The `family` param: a non-empty name without control characters, trimmed. +fn family(p: &Value, cmd: &str) -> Result { + let name = p.get("family").and_then(Value::as_str).ok_or_else(|| bad(cmd, "pass `family`, a font family name"))?.trim(); + if name.is_empty() { + return Err(bad(cmd, "`family` is empty")); + } + if name.chars().count() > MAX_FAMILY_CHARS { + return Err(bad(cmd, format!("`family` is longer than {MAX_FAMILY_CHARS} characters"))); + } + if name.chars().any(char::is_control) { + return Err(bad(cmd, "`family` contains control characters")); + } + Ok(name.to_string()) +} + +/// The favourites as the menu shows them: valid names, once each, at most [`MAX_FAVORITE_FONTS`]. +pub fn favorite_fonts(s: &Session) -> Vec { + let mut out: Vec = Vec::new(); + for f in &s.prefs().type_.favorite_fonts { + if out.len() >= MAX_FAVORITE_FONTS { + break; + } + if !f.trim().is_empty() && !out.contains(f) { + out.push(f.clone()); + } + } + out +} + +/// Star or un-star a family (`on` sets it; without it the star toggles). +fn favorite(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "type.font.favorite"; + let name = family(p, CMD)?; + let mut list = favorite_fonts(s); + let on = match p.get("on") { + None => !list.contains(&name), + Some(v) => v.as_bool().ok_or_else(|| bad(CMD, "`on` must be true or false"))?, + }; + list.retain(|f| *f != name); + if on { + if list.len() >= MAX_FAVORITE_FONTS { + return Err(bad(CMD, format!("at most {MAX_FAVORITE_FONTS} favourite fonts"))); + } + list.push(name.clone()); + } + let stored = list.clone(); + s.prefs.edit(|p| p.type_.favorite_fonts = stored); + Ok(json!({"family": name, "favorite": on, "favorites": list})) +} + +fn library(s: &mut Session, _: &Value) -> Result { + Ok(json!({"favorites": favorite_fonts(s)})) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "type.font.favorite", + label: "Add to Favorites", + menu: &[], + shortcut: None, + params: r#"{"family":str,"on":bool?=toggle} → family, favorite, favorites"#, + enabled: always, + run: favorite, + journal: false, + }, + CommandSpec { + id: "type.font.library", + label: "Font Library", + menu: &[], + shortcut: None, + params: "{} → favorites", + enabled: always, + run: library, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/font_library_cmds/tests.rs b/crates/engine/src/cmd/font_library_cmds/tests.rs new file mode 100644 index 0000000000..c31c65353a --- /dev/null +++ b/crates/engine/src/cmd/font_library_cmds/tests.rs @@ -0,0 +1,79 @@ +use serde_json::json; + +use super::*; + +fn session() -> Session { + Session::new() +} + +#[test] +fn starring_toggles_and_on_sets_a_favourite() { + let mut s = session(); + s.execute("type.font.favorite", json!({"family": "Arial"})).unwrap(); + s.execute("type.font.favorite", json!({"family": "Bebas Neue"})).unwrap(); + assert_eq!(favorite_fonts(&s), ["Arial", "Bebas Neue"]); + // Toggling again removes it; `on` sets it either way. + let r = s.execute("type.font.favorite", json!({"family": "Arial"})).unwrap(); + assert_eq!(r["favorite"], false); + s.execute("type.font.favorite", json!({"family": "Bebas Neue", "on": true})).unwrap(); + assert_eq!(favorite_fonts(&s), ["Bebas Neue"], "on: true doesn't duplicate"); + s.execute("type.font.favorite", json!({"family": "Bebas Neue", "on": false})).unwrap(); + assert!(favorite_fonts(&s).is_empty()); +} + +#[test] +fn resetting_the_type_preferences_keeps_the_favourites() { + let mut s = session(); + s.execute("type.font.favorite", json!({"family": "Bebas Neue"})).unwrap(); + s.execute("prefs.set", json!({"path": "type.smartQuotes", "value": false})).unwrap(); + s.execute("prefs.reset", json!({"path": "type"})).unwrap(); + assert!(s.prefs().type_.smart_quotes, "the Type settings are reset"); + assert_eq!(favorite_fonts(&s), ["Bebas Neue"], "the favourites are the user's library, not a setting"); + // Asked for by name, or with everything, they go. + s.execute("prefs.reset", json!({"path": "type.favoriteFonts"})).unwrap(); + assert!(favorite_fonts(&s).is_empty()); + s.execute("type.font.favorite", json!({"family": "Bebas Neue"})).unwrap(); + s.execute("prefs.reset", json!({})).unwrap(); + assert!(favorite_fonts(&s).is_empty()); +} + +#[test] +fn the_library_survives_a_preferences_round_trip_and_hostile_lists_are_tidied() { + let mut s = session(); + s.execute("type.font.favorite", json!({"family": "Arial"})).unwrap(); + let saved = serde_json::to_value(s.prefs()).unwrap(); + assert_eq!(saved["type"]["favoriteFonts"], json!(["Arial"])); + let mut t = session(); + let prefs = serde_json::from_value(saved).unwrap(); + t.prefs.edit(|p| *p = prefs); + let lib = t.execute("type.font.library", json!({})).unwrap(); + assert_eq!(lib, json!({"favorites": ["Arial"]})); + // Duplicates, blanks and an oversized list from a hand-edited file are tidied on read. + t.prefs.edit(|p| p.type_.favorite_fonts = (0..5000).map(|i| format!("F{}", i % 1200)).chain([String::new()]).collect()); + assert_eq!(favorite_fonts(&t).len(), MAX_FAVORITE_FONTS); +} + +#[test] +fn bad_params_are_errors_and_change_nothing() { + let mut s = session(); + s.execute("type.font.favorite", json!({"family": "Arial"})).unwrap(); + for (id, p) in [ + ("type.font.favorite", json!({})), + ("type.font.favorite", json!({"family": 42})), + ("type.font.favorite", json!({"family": " "})), + ("type.font.favorite", json!({"family": "a\u{0}b"})), + ("type.font.favorite", json!({"family": "x".repeat(300)})), + ("type.font.favorite", json!({"family": "Arial", "on": "yes"})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + assert_eq!(favorite_fonts(&s), ["Arial"]); +} + +#[test] +fn font_library_edits_are_not_recorded_into_actions() { + // Playing an action back must not rewrite the user's favourite fonts. + for id in ["type.font.favorite", "type.font.library"] { + assert!(!crate::actions_cmds::replayable(id), "{id}"); + } +} diff --git a/crates/engine/src/cmd/frame_cmds.rs b/crates/engine/src/cmd/frame_cmds.rs index 2ea953609f..c2cd6283a7 100644 --- a/crates/engine/src/cmd/frame_cmds.rs +++ b/crates/engine/src/cmd/frame_cmds.rs @@ -54,11 +54,8 @@ fn frame_from_layers(s: &mut Session, p: &Value) -> Result { Err(e) => { // The group couldn't be framed: take the grouping back, leaving nothing to redo and // no grouping step to record. - s.undo(); + s.rollback(); s.journal.truncate(journal); - if let Some(st) = s.active_mut() { - st.history.clear_redo(); - } Err(e) } } @@ -149,9 +146,15 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { crate::layer_multi_cmds::check_group_depth(doc, "New Frame")?; gid } - None => doc.insert_above(*active, Layer::group(name, vec![])), + None => crate::layer_place::insert_new(doc, *active, Layer::group(name, vec![]), crate::layer_place::Place::AutoEditable), }; - doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?.mask = Some(mask); + let frame = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; + frame.mask = Some(mask); + // A frame is one row, as in Photoshop: closed, so the next new layer goes above it rather + // than into it. + if let LayerContent::Group(g) = &mut frame.content { + g.expanded = false; + } *active = Some(gid); Ok(gid) })?; diff --git a/crates/engine/src/cmd/gradient_fill_cmds.rs b/crates/engine/src/cmd/gradient_fill_cmds.rs index 138be5bbeb..41f91f8bbb 100644 --- a/crates/engine/src/cmd/gradient_fill_cmds.rs +++ b/crates/engine/src/cmd/gradient_fill_cmds.rs @@ -322,7 +322,7 @@ fn create(s: &mut Session, p: &Value) -> Result { new_layer(s, &d.doc, p)? }; let id = s.edit("New Gradient Fill Layer", |doc, active| { - let id = doc.insert_above(*active, layer); + let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index 0596bbb12d..d8bea8607b 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -39,27 +39,41 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { /// The `state` param as an index into the active document's history entries, defaulting to the /// current state. fn state_index(s: &Session, cmd: &str, p: &Value) -> Result<(usize, usize)> { + state_index_upto(s, cmd, p, false) +} + +/// [`state_index`], also accepting the later (dimmed, redo) states with `later`. +fn state_index_upto(s: &Session, cmd: &str, p: &Value, later: bool) -> Result<(usize, usize)> { let st = s.active().ok_or(EngineError::NoDocument)?; let current = st.history.past_len(); + let last = if later { current.saturating_add(st.history.redo_labels().count()) } else { current }; let i = match p.get("state") { None | Some(Value::Null) => current, Some(_) => { let v = int(p, "state").ok_or_else(|| bad(cmd, "`state` must be a number"))?; - usize::try_from(v).ok().filter(|i| *i <= current).ok_or_else(|| bad(cmd, format!("`state` = {v} is not a state (0..={current})")))? + usize::try_from(v).ok().filter(|i| *i <= last).ok_or_else(|| bad(cmd, format!("`state` = {v} is not a state (0..={last})")))? } }; Ok((i, current)) } /// History panel › Delete: drop the state and every state after it, as Photoshop does in linear -/// history. The document returns to the state before it, and the deleted steps can't be redone. -/// The oldest state (the document as opened) can't be deleted. +/// history (in non-linear history only that state, also a later one: +/// `history_options_cmds::delete_state`). The document returns to the state before it, and the +/// deleted steps can't be redone. The oldest state (the document as opened) can't be deleted. fn delete_state(s: &mut Session, p: &Value) -> Result { const CMD: &str = "history.deleteState"; - let (i, current) = state_index(s, CMD, p)?; + // History Options › Allow Non-Linear History: only that state goes, a later (dimmed) one too. + let non_linear = s.active().is_some_and(|st| st.history.non_linear); + let (i, current) = state_index_upto(s, CMD, p, non_linear)?; if i == 0 { return Err(bad(CMD, "the oldest state can't be deleted")); } + if non_linear { + crate::history_options_cmds::delete_state(s, i, current)?; + let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries(); + return Ok(json!({ "states": entries.len(), "current": entries.last() })); + } // Step back to the state before `i`; the steps from `i` on are then redo states. for _ in i..=current { if !s.undo() { @@ -199,8 +213,13 @@ fn delete_snapshot(s: &mut Session, p: &Value) -> Result { Ok(json!({ "snapshot": snap.id, "snapshots": st.snapshots.len() })) } +/// A state other than the oldest exists to delete: a past or the current one, and with Allow +/// Non-Linear History a later (dimmed) one too, also while the oldest state is current. fn can_delete(s: &Session) -> std::result::Result<(), String> { - s.active().filter(|d| d.history.past_len() > 0).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) + s.active() + .filter(|d| d.history.past_len() > 0 || (d.history.non_linear && d.history.can_redo())) + .map(|_| ()) + .ok_or_else(|| "the oldest state can't be deleted".into()) } fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -245,7 +264,7 @@ pub fn specs() -> Vec { label: "Delete State", menu: &[], shortcut: None, - params: r##"{"state":index?} (History entry, 0 = oldest; default the current state) → {states,current}: removes the state and every later one"##, + params: r##"{"state":index?} (History entry, 0 = oldest; default the current state) → {states,current}: removes the state and every later one (with Allow Non-Linear History only that state, a later dimmed one too)"##, enabled: can_delete, run: delete_state, journal: true, diff --git a/crates/engine/src/cmd/history_options_cmds.rs b/crates/engine/src/cmd/history_options_cmds.rs new file mode 100644 index 0000000000..17341b5ae1 --- /dev/null +++ b/crates/engine/src/cmd/history_options_cmds.rs @@ -0,0 +1,561 @@ +//! History panel › History Options… (#2966): Allow Non-Linear History and Make Layer Visibility +//! Changes Undoable. The values are preferences (`historyOptions.*`, also `prefs.set`), applied +//! by [`Session::apply_prefs`] and [`Session::edit_visibility`]. Without params the command +//! reports them; the GUI opens its dialog instead and runs it with the dialog's values on OK. +//! +//! Photoshop's three snapshot options (Automatically Create First Snapshot, Automatically +//! Create New Snapshot When Saving, Show New Snapshot Dialog by Default) come with #3634, which +//! makes the snapshots act on them. + +use serde_json::{Map, Value}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "history.options"; + +/// The option keys, as in `prefs.get historyOptions` and the command's params. +pub const OPTIONS: [&str; 2] = ["allowNonLinearHistory", "makeLayerVisibilityChangesUndoable"]; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// The current History Options as `{allowNonLinearHistory, makeLayerVisibilityChangesUndoable}`. +pub fn current(s: &Session) -> Value { + let o = &s.prefs().history_options; + serde_json::json!({ + "allowNonLinearHistory": o.allow_non_linear_history, + "makeLayerVisibilityChangesUndoable": o.make_layer_visibility_changes_undoable, + }) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let given: Map = match p { + Value::Null => Map::new(), + Value::Object(m) => m.clone(), + _ => return Err(bad("params must be an object")), + }; + // Validate everything before changing anything. + let mut set = Vec::new(); + for (k, v) in &given { + if !OPTIONS.contains(&k.as_str()) { + return Err(bad(format!("unknown option `{k}` (expected {})", OPTIONS.join(", ")))); + } + let b = v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false")))?; + set.push((k.clone(), b)); + } + if !set.is_empty() { + s.edit_prefs(|prefs| { + for (k, b) in &set { + match k.as_str() { + "allowNonLinearHistory" => prefs.history_options.allow_non_linear_history = *b, + _ => prefs.history_options.make_layer_visibility_changes_undoable = *b, + } + } + }); + } + Ok(current(s)) +} + +/// History panel › Delete in non-linear history: delete state `i` (numbered as the History panel +/// lists them, `current` = the current state's index, the later dimmed states after it) and +/// nothing else. Deleting the current state steps back to the one before it; the later states +/// stay. +pub(crate) fn delete_state(s: &mut Session, i: usize, current: usize) -> Result<()> { + if i == 0 { + return Err(EngineError::BadParams { cmd: "history.deleteState".into(), msg: "the oldest state can't be deleted".into() }); + } + // A background job is computing from the current state: it must not move under it. + if s.active_job().is_some() { + return Err(EngineError::Other("the history can't step back right now".into())); + } + let keep_visibility = !s.visibility_undoable(); + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + st.coalesce = None; + if i != current { + let removed = match i.checked_sub(current.saturating_add(1)) { + Some(j) => st.history.remove_later(j), + None => st.history.remove(i), + }; + if !removed { + return Err(EngineError::Other(format!("state {i} is not held"))); + } + // The document is unchanged, but the History panel's rows are not: a view-only change + // that keeps a clean document clean. + let clean = st.saved_revision == st.revision; + st.revision += 1; + st.view_revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + return Ok(()); + } + // The step that made the current state: the layers whose visibility it changed get it back. + let changed = st.history.current_visibility().to_vec(); + let (mut d, layers) = st.history.drop_current().ok_or_else(|| EngineError::Other("the history can't step back right now".into()))?; + // As Undo does: Save As changes file identity outside history. + if d.name != st.doc.name { + std::sync::Arc::make_mut(&mut d).name.clone_from(&st.doc.name); + } + if keep_visibility { + crate::carry_visibility(&st.doc, &mut d, &changed); + } + let damage = crate::layer_multi_cmds::step_damage(&d, &st.doc); + st.doc = d; + crate::restore_target(st, layers); + st.revision += 1; + st.last_damage = damage; + Ok(()) +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "History Options…", + menu: &[], + shortcut: None, + params: r##"{"allowNonLinearHistory":bool?,"makeLayerVisibilityChangesUndoable":bool?} → both values (no params: report them). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##, + enabled: always, + run: options, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn entries(s: &Session) -> Vec { + s.active().unwrap().history.entries() + } + + /// A document with a layer above the background: entries Open, New Layer. + fn doc() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + (s, id) + } + + #[test] + fn defaults_match_photoshop_and_round_trip_through_prefs() { + let mut s = Session::new(); + assert_eq!(s.execute(CMD, json!({})).unwrap(), json!({"allowNonLinearHistory": false, "makeLayerVisibilityChangesUndoable": true})); + assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions"})).unwrap(), current(&s)); + let r = s.execute(CMD, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})).unwrap(); + assert_eq!(r, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})); + assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions.allowNonLinearHistory"})).unwrap(), json!(true)); + // Saved and restored with the other preferences. + let saved = s.prefs_to_json(); + let mut t = Session::new(); + t.load_prefs_json(&saved).unwrap(); + assert_eq!(current(&t), r); + // prefs.set and prefs.reset reach them too. + t.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": false})).unwrap(); + assert_eq!(t.execute(CMD, Value::Null).unwrap()["allowNonLinearHistory"], json!(false)); + t.execute("prefs.reset", json!({"path": "historyOptions"})).unwrap(); + assert_eq!(current(&t), current(&Session::new())); + // Older preference files without the section load with the defaults. + t.load_prefs_json("{}").unwrap(); + assert_eq!(current(&t), current(&Session::new())); + } + + #[test] + fn bad_params_are_refused_and_change_nothing() { + let mut s = Session::new(); + let before = current(&s); + for p in [ + json!({"allowNonLinearHistory": "yes"}), + json!({"allowNonLinearHistory": 1}), + json!({"makeLayerVisibilityChangesUndoable": null}), + json!({"allowNonLinearHistory": true, "snapshots": true}), + json!([true]), + json!("x"), + json!(3), + ] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + assert_eq!(current(&s), before, "{p} changed nothing"); + } + assert!(s.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": "on"})).is_err()); + assert_eq!(current(&s), before); + } + + #[test] + fn layer_visibility_is_a_history_state_only_when_undoable() { + // On (the default): the eye, Hide/Show Layers and ⌥-click each add a state. + let (mut s, id) = doc(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert_eq!(entries(&s).last().map(String::as_str), Some("Layer Visibility")); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().visible); + + // Off: the document changes (and is modified) but History doesn't. + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + let before = entries(&s); + let rev = s.active().unwrap().revision; + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + s.execute("layer.showLayers", json!({"layer": id})).unwrap(); + s.execute("layer.hideLayers", json!({"layer": id})).unwrap(); + s.execute("layer.showOnly", json!({"layer": id})).unwrap(); + s.execute("layer.showOnly", json!({"layer": id})).unwrap(); + // Hide Layers toggles (⌘,), so show the layer first for it to hide. + s.execute("layer.showLayers", json!({"layer": id})).unwrap(); + s.execute("layer.hideLayers", json!({"layer": id})).unwrap(); + assert_eq!(entries(&s), before, "no visibility state was recorded"); + let st = s.active().unwrap(); + assert!(!st.doc.layer(photocraft_doc::LayerId(id)).unwrap().visible, "the change still applies"); + assert!(st.revision > rev && st.is_dirty()); + // Other properties still record a state. + s.execute("layer.setProps", json!({"layer": id, "opacity": 0.5})).unwrap(); + assert_eq!(entries(&s).len(), before.len() + 1); + // An unrecorded change leaves the redo states alone. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap(); + assert!(s.active().unwrap().history.can_redo()); + } + + fn visible(s: &Session, id: u64) -> bool { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().visible + } + + fn red(s: &Session, id: u64) -> bool { + let st = s.active().unwrap(); + st.doc.layer(photocraft_doc::LayerId(id)).unwrap().surface().unwrap().pixel(1, 1)[0] > 0.5 + } + + /// Off, showing or hiding a layer is not a state, so stepping through history (Undo, Redo, a + /// History row) leaves the visibility as the user set it, as Photoshop does; history states + /// hold whole documents and would otherwise bring the old visibility back. + #[test] + fn stepping_through_history_keeps_unrecorded_visibility() { + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert!(s.execute("edit.undo", json!({})).unwrap().as_bool().unwrap()); + assert!(!red(&s, id), "the fill is undone"); + assert!(!visible(&s, id), "the layer stays hidden"); + s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + assert!(red(&s, id) && visible(&s, id), "redo brings the fill back, not the hidden layer"); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + // History rows: New, New Layer, Fill. + s.execute("history.selectState", json!({"state": 1})).unwrap(); + assert!(!red(&s, id) && !visible(&s, id)); + s.execute("history.selectState", json!({"state": 2})).unwrap(); + assert!(red(&s, id) && !visible(&s, id)); + + // On (the default): Undo takes the hide back first, then the fill. + let (mut s, id) = doc(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(red(&s, id) && visible(&s, id)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!red(&s, id) && visible(&s, id)); + s.execute("history.selectState", json!({"state": 3})).unwrap(); + assert!(red(&s, id) && !visible(&s, id), "the hide is a state of its own"); + } + + /// Off, only the unrecorded visibility changes carry across history: a recorded step that + /// changed visibility (Apply Layer Comp) gives it back when undone and sets it again when + /// redone, whether by Undo / Redo, a History row or deleting the current state. + #[test] + fn stepping_over_a_recorded_visibility_change_restores_it() { + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + // A comp with the layer hidden, then the eye shows it again (no state) and a fill. + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap(); + assert!(red(&s, id) && !visible(&s, id), "the comp hides the layer"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(red(&s, id) && visible(&s, id), "undoing Apply Layer Comp shows the layer again"); + // The eye hides it (no state): undoing the fill keeps that. + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!red(&s, id) && !visible(&s, id), "the unrecorded hide carries across the fill's undo"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(red(&s, id) && !visible(&s, id)); + s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + assert!(red(&s, id) && !visible(&s, id), "redoing the comp hides the layer again"); + + // History rows: New, New Layer, New Layer Comp, Fill, Apply Layer Comp. + assert_eq!(entries(&s).len(), 5); + s.execute("history.selectState", json!({"state": 2})).unwrap(); + assert!(!red(&s, id) && visible(&s, id), "jumping back over the comp shows the layer again"); + s.execute("history.selectState", json!({"state": 4})).unwrap(); + assert!(red(&s, id) && !visible(&s, id), "jumping forward over the comp hides it again"); + + // Non-linear Delete of the current state (the comp) steps back and shows the layer too. + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + s.execute("history.deleteState", json!({})).unwrap(); + assert!(red(&s, id) && visible(&s, id), "deleting the comp's state shows the layer again"); + + // Non-linear Delete of a past state: the step after it now starts from the state before + // it, so undoing that step also gives back the visibility the deleted step changed. + s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let comp = entries(&s).len() - 2; + s.execute("history.deleteState", json!({"state": comp})).unwrap(); + assert!(!visible(&s, id)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(red(&s, id) && visible(&s, id), "back at the red fill, where the layer was shown"); + } + + #[test] + fn non_linear_history_keeps_later_states_and_delete_removes_only_one() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + for c in ["#ff0000", "#00ff00", "#0000ff"] { + s.execute("edit.fill", json!({"color": c})).unwrap(); + } + let filled = entries(&s); + assert_eq!(filled.len(), 4); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let after = entries(&s); + assert_eq!(after.len(), 5, "the two later fills are kept: {after:?}"); + assert_eq!(after[..4], filled[..]); + // Delete the second fill only: the states after it stay. + s.execute("history.deleteState", json!({"state": 2})).unwrap(); + let mut expected = after.clone(); + expected.remove(2); + assert_eq!(entries(&s), expected); + // Deleting the current state steps back to the one before; nothing else goes. + let doc_before = s.active().unwrap().history.state(expected.len() - 2).unwrap(); + s.execute("history.deleteState", json!({})).unwrap(); + assert_eq!(entries(&s), expected[..expected.len() - 1].to_vec()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc_before)); + // After stepping back, deleting the current state keeps the later (redo) states. + s.execute("edit.undo", json!({})).unwrap(); + let redo: Vec = s.active().unwrap().history.redo_labels().map(str::to_string).collect(); + s.execute("history.deleteState", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.redo_labels().map(str::to_string).collect::>(), redo); + + // Turning it off applies to open documents: the next edit after stepping back discards. + s.execute(CMD, json!({"allowNonLinearHistory": false})).unwrap(); + assert!(!s.active().unwrap().history.non_linear); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + assert!(!s.active().unwrap().history.can_redo()); + // New documents pick the option up too. + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(s.active().unwrap().history.non_linear); + } + + /// With the oldest state current, non-linear Delete is still enabled for a later (dimmed) + /// state (the History panel's trash and its drop target ask for it); linear history has + /// nothing to delete there. + #[test] + fn non_linear_delete_is_enabled_while_the_oldest_state_is_current() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + for c in ["#ff0000", "#00ff00"] { + s.execute("edit.fill", json!({"color": c})).unwrap(); + } + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.is_enabled("history.deleteState"), "linear: only the oldest state is left to delete"); + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + assert!(s.is_enabled("history.deleteState")); + let doc = s.active().unwrap().doc.clone(); + s.execute("history.deleteState", json!({"state": 1})).unwrap(); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document is unchanged"); + assert_eq!(st.history.redo_labels().count(), 1, "only the first fill went"); + assert!(s.execute("history.deleteState", json!({})).is_err(), "the oldest state itself stays"); + s.execute("history.deleteState", json!({"state": 1})).unwrap(); + assert!(!s.is_enabled("history.deleteState"), "nothing left but the oldest state"); + } + + /// Non-linear Delete of a past or later state leaves the document as it is, so a saved + /// (clean) document stays clean; deleting the current state steps back and modifies it. + #[test] + fn non_linear_delete_of_another_state_keeps_a_clean_document_clean() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + for c in ["#ff0000", "#00ff00", "#0000ff", "#808080"] { + s.execute("edit.fill", json!({"color": c})).unwrap(); + } + s.execute("edit.undo", json!({})).unwrap(); + // States: New, Fill 1-3 (3 current), the dimmed Fill 4. As if just saved. + let st = s.active_mut().unwrap(); + st.saved_revision = st.revision; + let rev = st.revision; + s.execute("history.deleteState", json!({"state": 1})).unwrap(); + let st = s.active().unwrap(); + assert!(!st.is_dirty(), "deleting a past state left the saved document unmodified"); + assert!(st.revision > rev, "the History panel still sees the change"); + s.execute("history.deleteState", json!({"state": 3})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "deleting a later state left it unmodified too"); + assert_eq!(s.active().unwrap().history.redo_labels().count(), 0); + // A modified document stays modified. + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + s.execute("history.deleteState", json!({"state": 1})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + // The current state: the document steps back, which is a change. + let st = s.active_mut().unwrap(); + st.saved_revision = st.revision; + s.execute("history.deleteState", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + } + + /// With the option off, a recorded step that changes visibility along with other properties + /// (layer.setProps visible + opacity is one Layer Properties state) is undone and redone as a + /// whole: the carried-over visibility skips the layers the step itself changed. + #[test] + fn undoing_a_mixed_visibility_and_opacity_change_undoes_both() { + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + let n = entries(&s).len(); + s.execute("layer.setProps", json!({"layer": id, "visible": false, "opacity": 0.5})).unwrap(); + assert_eq!(entries(&s).len(), n + 1, "the mixed change is a state"); + let opacity = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().opacity; + s.execute("edit.undo", json!({})).unwrap(); + assert!(visible(&s, id) && (opacity(&s) - 1.0).abs() < 1e-6, "undo shows the layer again and restores its opacity"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(!visible(&s, id) && (opacity(&s) - 0.5).abs() < 1e-6, "redo hides it again"); + } + + /// Non-linear Delete reaches a later (dimmed) state too, and deletes only it; linear history + /// still refuses one past the current state. + #[test] + fn non_linear_delete_removes_a_later_state() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + for c in ["#ff0000", "#00ff00", "#0000ff"] { + s.execute("edit.fill", json!({"color": c})).unwrap(); + } + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.execute("history.deleteState", json!({"state": 2})).is_err(), "linear: not a deletable state"); + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut expected = entries(&s); + let mut redo: Vec = s.active().unwrap().history.redo_labels().map(str::to_string).collect(); + // Entries: New, Fill (current), then the dimmed Fill 2 (state 2) and Fill 3 (state 3). + s.execute("history.deleteState", json!({"state": 2})).unwrap(); + redo.remove(0); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document is unchanged"); + assert_eq!(st.history.redo_labels().map(str::to_string).collect::>(), redo); + assert_eq!(entries(&s), expected); + assert!(s.execute("history.deleteState", json!({"state": 4})).is_err(), "past the last state"); + s.execute("edit.redo", json!({})).unwrap(); + expected.push(redo[0].clone()); + assert_eq!(entries(&s), expected); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().pixel(1, 1); + assert!(px[2] > 0.5 && px[1] < 0.5, "redo reaches the blue fill: {px:?}"); + } + + /// Off, taking a step back with `Session::rollback` (a cancelled Free Transform of a made + /// layer, an abandoned Alt-drag copy, a compound command whose later part failed) keeps the + /// unrecorded visibility as Undo does, and gives back the visibility the step itself changed, + /// whether it takes the step back exactly, falls back to Undo or lands on the oldest state. + #[test] + fn rollback_keeps_unrecorded_visibility() { + // Exactly: the fill is taken back, the eye's hide stays. + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert!(s.rollback()); + assert!(!red(&s, id), "the fill is taken back"); + assert!(!visible(&s, id), "the unrecorded hide stays"); + assert!(!s.active().unwrap().history.can_redo(), "nothing to redo"); + + // Landing on the oldest state (the New Layer step taken back): the hidden background stays + // hidden. + let (mut s, _) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id.0; + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + assert!(s.rollback()); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "the new layer is taken back"); + assert!(!visible(&s, bg), "the unrecorded hide stays on the oldest state"); + assert!(!s.rollback(), "nothing left to take back"); + assert!(!visible(&s, bg)); + + // A step that changed visibility (Apply Layer Comp) gives it back. + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap(); + s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap(); + assert!(!visible(&s, id)); + assert!(s.rollback()); + assert!(visible(&s, id), "taking back Apply Layer Comp shows the layer again"); + + // Falling back to Undo (the history changed since the fill: undo, redo). + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert!(s.rollback()); + assert!(!red(&s, id) && !visible(&s, id), "the fallback Undo keeps the hide too"); + assert!(!s.active().unwrap().history.can_redo()); + + // Non-linear: a step recorded after stepping back, taken back exactly, keeps the hide and + // gives the later state back to redo. + let (mut s, id) = doc(); + s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false, "allowNonLinearHistory": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert!(s.rollback()); + assert!(!red(&s, id) && !visible(&s, id), "back before the invert, still hidden"); + assert!(s.active().unwrap().history.can_redo(), "the fill is redoable again"); + + // On (the default) the eye records a state of its own, which a rollback takes back. + let (mut s, id) = doc(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + assert!(s.rollback()); + assert!(red(&s, id) && visible(&s, id), "the hide is taken back, the fill stays"); + } + + /// Non-linear, a step that moves more later states than the History States limit leaves room + /// for is still taken back exactly by a rollback (a compound command whose later part failed): + /// the document before it comes back with the full list, not an Undo onto the last later + /// state that also drops the rest. + #[test] + fn rollback_past_the_state_limit_restores_the_list() { + let (mut s, id) = doc(); + s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let before = entries(&s); + let redo: Vec = s.active().unwrap().history.redo_labels().map(str::to_string).collect(); + assert_eq!(redo.len(), 2); + s.active_mut().unwrap().history.max_states = 2; + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + assert!(s.rollback()); + assert_eq!(entries(&s), before); + assert_eq!(s.active().unwrap().history.redo_labels().map(str::to_string).collect::>(), redo); + assert!(!red(&s, id), "back on the New Layer state, not on the inverted fill"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(red(&s, id), "the red fill is still redoable"); + } +} diff --git a/crates/engine/src/cmd/history_select_cmds.rs b/crates/engine/src/cmd/history_select_cmds.rs index 2409722121..3b04fe0221 100644 --- a/crates/engine/src/cmd/history_select_cmds.rs +++ b/crates/engine/src/cmd/history_select_cmds.rs @@ -32,6 +32,7 @@ fn select_state(s: &mut Session, p: &Value) -> Result { if s.active_job().is_some() { return Err(EngineError::Other("the history can't move while a command is still running on this document".into())); } + let keep_visibility = !s.visibility_undoable(); let st = s.active_mut().ok_or(EngineError::NoDocument)?; if target == past { return Ok(json!({ "state": target, "changed": false })); @@ -44,8 +45,19 @@ fn select_state(s: &mut Session, p: &Value) -> Result { let mut layers = None; let mut at = past; while at != target { - let step = if target < at { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) }; - let Some((d, l)) = step else { break }; + // History Options: visibility changes that are not history states stay as they are, + // step by step as Undo / Redo keep them, except on the layers whose visibility the step + // passed over changed. + let undoing = target < at; + let mut changed = if undoing { st.history.current_visibility().to_vec() } else { Vec::new() }; + let step = if undoing { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) }; + let Some((mut d, l)) = step else { break }; + if keep_visibility { + if !undoing { + changed = st.history.current_visibility().to_vec(); + } + crate::carry_visibility(&doc, &mut d, &changed); + } doc = d; layers = Some(l); at = if target < at { at - 1 } else { at + 1 }; diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs index 78e72f0a7b..631cceac65 100644 --- a/crates/engine/src/cmd/layer_copy_cmds.rs +++ b/crates/engine/src/cmd/layer_copy_cmds.rs @@ -216,8 +216,11 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { doc.patterns.extend(patterns); let mut above = *active; let mut new = Vec::with_capacity(copies.layers.len()); + // The first copy goes where a new layer would; the rest stack above it. + let mut place = crate::layer_place::Place::AutoEditable; for l in copies.layers { - let id = doc.insert_above(above, l); + let id = crate::layer_place::insert_new(doc, above, l, place); + place = crate::layer_place::Place::Above; above = Some(id); new.push(id); } diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index b0cceb72a8..702d65891b 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -432,7 +432,7 @@ fn stack_modes_render_from_the_nested_layers() { surf.write_region(Rect::new(3, 3, 4, 4), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0])); kids.push(l); } - let g = Layer::new("stack", LayerContent::Group(photocraft_doc::Group { children: kids, expanded: true, artboard: None })); + let g = Layer::new("stack", LayerContent::Group(photocraft_doc::Group { children: kids, expanded: true, artboard: None, frame_stroke: None })); let smart = crate::smart_cmds::layer_to_smart(doc, &g)?; let id = smart.id; doc.layers = vec![smart]; diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index eb927039c5..52f36b9ffa 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -5,7 +5,7 @@ //! The selection itself lives in [`DocState::selected_layers`] (UI state, like the active layer): //! changing it never adds a history step. Every structural change below is one history step. -use photocraft_color::{PixelFormat, SampleType}; +use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, VideoData}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -141,7 +141,7 @@ pub fn specs() -> Vec { "Group from Layers…", &["Layer", "New"], None, - r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + r##"{"layer":id?,"name":text?,"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":str="passThrough" (blend mode),"opacity":0..100=100 (percent)} (no layer: every selected layer; the new group starts collapsed)"##, has_layer, group_layers ), diff --git a/crates/engine/src/cmd/layer_multi_cmds/align.rs b/crates/engine/src/cmd/layer_multi_cmds/align.rs index c1477f58a7..e33b11b24a 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/align.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/align.rs @@ -1,7 +1,7 @@ use super::damage::align_bounds; use super::moves::{move_layers, with_links}; use super::selection::{selected, top_level}; -use super::*; +use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Edge { diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs index 040a6ee5d7..b76c7fd059 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs @@ -1,4 +1,6 @@ -use super::*; +use photocraft_color::{PixelFormat, SampleType}; + +use super::{Document, Layer, LayerContent, LayerId, Rect, Session, VideoData}; /// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents /// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere. diff --git a/crates/engine/src/cmd/layer_multi_cmds/moves.rs b/crates/engine/src/cmd/layer_multi_cmds/moves.rs index a8f4a04918..822e0f6ad1 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/moves.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/moves.rs @@ -1,7 +1,7 @@ use super::damage::note_damage; use super::selection::{reselect, same_named_targets, selected, top_level}; use super::structure::check_group_depth; -use super::*; +use super::{Document, EngineError, Layer, LayerContent, LayerId, Result, Session, Value, bad, int_i32, json}; /// A Layers-panel drag of the selected layer records its drop target by name (and which of /// several same-named layers it is), as [`recorded_selection`] does: the id means nothing in the diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 9400760c8e..8f66dfab7c 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -1,5 +1,5 @@ use super::moves::{link_groups, recorded_move}; -use super::*; +use super::{DocState, Document, EngineError, Layer, LayerContent, LayerId, Rect, Result, Session, Value, bad, json}; // ---------- selection state ---------- diff --git a/crates/engine/src/cmd/layer_multi_cmds/structure.rs b/crates/engine/src/cmd/layer_multi_cmds/structure.rs index 7800db630f..70aa512223 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/structure.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/structure.rs @@ -1,5 +1,5 @@ use super::selection::{reselect, selected, top_level}; -use super::*; +use super::{Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Result, Session, Value, bad, json}; // ---------- structural commands ---------- @@ -97,7 +97,7 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> { /// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves /// into a new, collapsed group placed where the top-most of them was. Bottom-to-top order is -/// preserved. +/// preserved. The New Group dialog's colour, mode and opacity are set in the same history step. pub fn group_layers(s: &mut Session, p: &Value) -> Result { let ids = match crate::commands::explicit_layer(s, p)? { Some(id) => vec![id], @@ -106,11 +106,15 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(EngineError::Other("no layer selected".into())); } - let name = p.get("name").and_then(Value::as_str).map(str::to_string); + let opts = crate::group_options::GroupOptions::parse("layer.groupLayers", p)?; let gid = s.edit("Group Layers", |doc, active| { let ids = top_level(doc, &ids); + // Artboards stay at the top level: inside a group they would stop being boards. + if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) { + return Err(EngineError::Other("artboards can't go into a group".into())); + } let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?; - let name = name.unwrap_or_else(|| doc.next_layer_name("Group")); + let name = opts.name.clone().unwrap_or_else(|| doc.next_layer_name("Group")); // Photoshop creates the group collapsed, so the panel shows one row in place of the layers. let mut group = Layer::group(name, vec![]); if let LayerContent::Group(g) = &mut group.content { @@ -121,7 +125,9 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { for id in &ids { children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?); } - *doc.layer_mut(gid).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(gid))? = children; + let group = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; + *group.children_mut().ok_or(EngineError::NoLayer(gid))? = children; + opts.apply(group); check_group_depth(doc, "Group Layers")?; *active = Some(gid); Ok(gid) @@ -278,7 +284,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul /// Show/hide every selected layer in one step. pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result { let sel = selected(s); - s.edit(if visible { "Show Layers" } else { "Hide Layers" }, |doc, _| { + s.edit_visibility(if visible { "Show Layers" } else { "Hide Layers" }, |doc, _| { for id in &sel { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.visible = visible; } diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index 0acf0068f5..689a9856b4 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -1,3 +1,5 @@ +use photocraft_color::SampleType; + use super::damage::{align_bounds, shape_pixel_bounds}; use super::*; @@ -999,6 +1001,31 @@ fn group_from_layers_preserves_order() { assert!(matches!(&doc(&s).layer(g).unwrap().content, LayerContent::Group(gr) if !gr.expanded)); } +/// Artboards are top-level groups; grouping them (now a plain click on the Layers panel's New +/// Group button with two boards selected) is refused and leaves them as they were. +#[test] +fn grouping_artboards_is_refused() { + let mut s = session(8); + let a = LayerId(s.execute("layer.new.artboard", json!({"rect": [0, 0, 100, 80]})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [120, 0, 100, 80]})).unwrap()["layer"].as_u64().unwrap()); + select_all(&mut s, &[a, b]); + let before = doc(&s).clone(); + let err = s.execute("layer.groupLayers", json!({})).unwrap_err(); + assert!(err.to_string().contains("artboards"), "{err}"); + assert!(s.execute("layer.groupLayers", json!({"layer": a.0})).is_err(), "one board is refused too"); + assert_eq!(doc(&s).layers.iter().map(|l| l.id).collect::>(), before.layers.iter().map(|l| l.id).collect::>()); + assert_eq!(doc(&s).artboards().len(), 2); + // A layer inside a board still groups, inside its board. (Made with no layer selected: a + // new layer with a board selected goes into that board, as in Photoshop.) + s.execute("select.deselectLayers", json!({})).unwrap(); + let inner = rect_layer(&mut s, Rect::new(0, 0, 5, 5)); + select_all(&mut s, &[inner]); + let c = LayerId(s.execute("layer.new.artboardFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(&s).artboard_of(inner), Some(c)); + let g = LayerId(s.execute("layer.groupLayers", json!({"layer": inner.0})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(&s).artboard_of(g), Some(c)); +} + #[test] fn grouping_is_capped_at_the_document_nesting_limit() { let mut s = session(8); diff --git a/crates/engine/src/cmd/layer_nav_cmds.rs b/crates/engine/src/cmd/layer_nav_cmds.rs index b41a18b590..bf02936418 100644 --- a/crates/engine/src/cmd/layer_nav_cmds.rs +++ b/crates/engine/src/cmd/layer_nav_cmds.rs @@ -118,8 +118,10 @@ mod tests { s.execute("layer.select", json!({"layer": c.0, "mode": "add"})).unwrap(); s.execute("layer.groupLayers", json!({})).unwrap(); let group = s.active().unwrap().doc.layers.iter().find(|l| l.is_group()).unwrap().id; - s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})).unwrap(); + // D goes above the closed group (an open one would take it inside, as in Photoshop). let d = id(&mut s, "layer.new.layer", json!({"name": "D"})); + // Group Layers makes a collapsed group; open it so the panel shows B and C. + s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})).unwrap(); (s, [bg, a, group, b, c, d]) } diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index baeb15af96..f298893b33 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -1,5 +1,5 @@ use super::params::bad; -use super::*; +use super::{BlendMode, Document, EngineError, Layer, LayerId, LayerMask, Result, SampleType, Session, Value}; /// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that /// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays diff --git a/crates/engine/src/cmd/layer_xmp_cmds.rs b/crates/engine/src/cmd/layer_xmp_cmds.rs index 4fb4eda32f..742e0adf8d 100644 --- a/crates/engine/src/cmd/layer_xmp_cmds.rs +++ b/crates/engine/src/cmd/layer_xmp_cmds.rs @@ -33,7 +33,7 @@ fn check_packet(raw: &str) -> Result<()> { if raw.contains('\0') { return Err(bad("`rawData` can't contain NUL characters")); } - let xml = roxmltree::Document::parse(raw).map_err(|e| bad(format!("`rawData` isn't well-formed XML: {e}")))?; + let xml = photocraft_io::xml::parse(raw, roxmltree::ParsingOptions::default()).map_err(|e| bad(format!("`rawData` isn't well-formed XML: {e}")))?; let root = xml.root_element().tag_name(); let xmp = matches!((root.namespace(), root.name()), (Some("adobe:ns:meta/"), "xmpmeta") | (Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#"), "RDF")); if !xmp { diff --git a/crates/engine/src/cmd/layer_xmp_cmds/tests.rs b/crates/engine/src/cmd/layer_xmp_cmds/tests.rs index 015f739399..5fb67ad13c 100644 --- a/crates/engine/src/cmd/layer_xmp_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_xmp_cmds/tests.rs @@ -61,6 +61,27 @@ fn sets_reads_and_removes_as_one_undo_step_each() { } } +/// A `rawData` packet nested 20 000 deep overflowed the XML parser's recursion (an abort nothing +/// can catch, from one command param); it is a param error now. The small stack makes a +/// regression abort instead of passing. +#[test] +fn deeply_nested_packets_are_refused_not_a_stack_overflow() { + let (s, a, _) = fixture(8); + let raw = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let (s, e) = std::thread::Builder::new() + .stack_size(1024 * 1024) + .spawn(move || { + let mut s = s; + let e = s.execute(ID, json!({"rawData": raw})).err(); + (s, e) + }) + .unwrap() + .join() + .unwrap(); + assert!(matches!(&e, Some(EngineError::BadParams { msg, .. }) if msg.contains("nested")), "{e:?}"); + assert_eq!(xmp(&s, a), None); +} + #[test] fn duplicating_a_layer_copies_its_packet() { let (mut s, a, _) = fixture(8); diff --git a/crates/engine/src/cmd/ops.rs b/crates/engine/src/cmd/ops.rs index 776f47e020..c106af6aac 100644 --- a/crates/engine/src/cmd/ops.rs +++ b/crates/engine/src/cmd/ops.rs @@ -1,6 +1,6 @@ use super::enabled::check_all_unlocked; use super::layer_params::{layer_param, mask_at_depth}; -use super::*; +use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels}; pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { let id = layer_param(s, p)?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index bd6396803d..f5d644dae7 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -1,5 +1,5 @@ use super::layer_params::layer_param; -use super::*; +use super::{EngineError, LayerId, Result, Session, Value, json}; // ---------- param helpers ---------- diff --git a/crates/engine/src/cmd/path_edit_cmds.rs b/crates/engine/src/cmd/path_edit_cmds.rs index 503ac8efae..d06b0fb29c 100644 --- a/crates/engine/src/cmd/path_edit_cmds.rs +++ b/crates/engine/src/cmd/path_edit_cmds.rs @@ -6,7 +6,8 @@ //! //! Every command targets a path like `path.transform`: `"name":"work"` (default), a saved path's //! name, or `"layer"` with `"layer":id?` for a shape layer's path (re-rendered; it stops being a -//! live shape) or a layer's vector mask. Knots are addressed by `subpath` and `knot` index, as +//! live shape), a layer's vector mask, or type on a path's Type Path (`"typePath":true` when the +//! type layer also has a vector mask). Knots are addressed by `subpath` and `knot` index, as //! listed by `path.info`, and the result is `{name, path}` in the compact JSON form. use photocraft_doc::{LayerContent, Path}; @@ -49,6 +50,23 @@ fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> { fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result { let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned(); let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None }; + // Type on a path: its own path, the text laid along it again (#9114). A vector mask on the + // layer stays what `"layer"` edits, as with `path.info`, unless `"typePath": true`. + let type_path = p.get("typePath").and_then(Value::as_bool).unwrap_or(false); + let typed = layer.filter(|id| { + s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| { + matches!(&l.content, LayerContent::Text(t) if crate::type_extra_cmds::type_path(t).is_some()) && (type_path || l.vector_mask.is_none()) + }) + }); + if let Some(id) = typed { + let path = crate::type_extra_cmds::with_text(s, &json!({"layer": id.0}), label, |t, _| { + let mut path = crate::type_extra_cmds::type_path(t).ok_or_else(|| bad(cmd, "the type layer is not on a path"))?; + f(&mut path).map_err(|e| bad(cmd, e))?; + crate::type_extra_cmds::set_type_path(t, &path)?; + Ok(path_json(&path)) + })?; + return Ok(json!({ "name": name, "path": path })); + } let shape = layer.filter(|id| s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))); let path = match shape { Some(id) => with_shape(s, id, label, |sh, _| { @@ -149,7 +167,7 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path or a vector mask)"#; + const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path, a vector mask, or else type on a path's Type Path),"typePath":bool=false (with "layer": the Type Path even when the type layer has a vector mask)"#; let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result| CommandSpec { id, label, diff --git a/crates/engine/src/cmd/pattern_cmds.rs b/crates/engine/src/cmd/pattern_cmds.rs index 9651874591..447e4c8dc7 100644 --- a/crates/engine/src/cmd/pattern_cmds.rs +++ b/crates/engine/src/cmd/pattern_cmds.rs @@ -397,7 +397,7 @@ pub(crate) fn insert_fill_layer( let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) } diff --git a/crates/engine/src/cmd/print_cmds.rs b/crates/engine/src/cmd/print_cmds.rs index d115566a7c..37b4e78e92 100644 --- a/crates/engine/src/cmd/print_cmds.rs +++ b/crates/engine/src/cmd/print_cmds.rs @@ -71,6 +71,176 @@ pub struct Spooler { pub run: SpoolFn, } +/// A stand-in for the system's printers, installed on `Session::printer_source` (tests): +/// `file.printers`, `file.print.printers` and the Print dialog list these and do not ask the spooler. +/// `file.print.printers` and the dialog's driver Printer menu list them on Windows only, where the +/// GDI route is; elsewhere they answer as that route does there. In the browser `file.printers` +/// lists none of them, as it lists no printers there without a stand-in. +pub type PrinterSource = std::sync::Arc Vec + Send + Sync>; + +/// Held while a thread of this process lists printers or calls into a printer driver. +static SPOOLER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Runs `f`, which lists printers or calls into a printer driver, while no other thread of this +/// process does either. On Windows, two threads that open printers' PrintTicket providers for the +/// first time at once can crash the process inside the spooler client (`winspool.drv`). A panic in +/// an earlier caller does not block later ones. A call made inside `f` on the same thread runs at +/// once. +pub fn with_spooler(f: impl FnOnce() -> T) -> T { + if HOLDING.get() { + return f(); + } + let _held = SPOOLER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + holding(f) +} + +thread_local! { + /// Whether this thread holds `SPOOLER`. + static HOLDING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Runs `f` with `HOLDING` set; called with `SPOOLER` held. +fn holding(f: impl FnOnce() -> T) -> T { + struct Release; + impl Drop for Release { + fn drop(&mut self) { + HOLDING.set(false); + } + } + HOLDING.set(true); + let _release = Release; + f() +} + +/// Counts the times this process asked the system for its printers. +static SYSTEM_LISTINGS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many times this process has asked the system for its printers. A session with a +/// `printer_source` does not ask. +pub fn system_printer_listings() -> usize { + SYSTEM_LISTINGS.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Runs `f` as `with_spooler` does, or returns `None` at once while another thread of this +/// process holds the lock (the UI thread, which must not wait for a print job or a driver). +pub fn try_with_spooler(f: impl FnOnce() -> T) -> Option { + if HOLDING.get() { + return Some(f()); + } + let _held = match SPOOLER.try_lock() { + Ok(held) => held, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return None, + }; + Some(holding(f)) +} + +/// What the Print dialog shows when `try_with_spooler` finds the lock held. +pub const SPOOLER_BUSY: &str = "the printer driver is busy with another print; try again when it finishes"; + +/// The GDI route's printers, or why they could not be listed. +pub type RgbPrinters = std::result::Result, craft_print::rgb::PrintError>; + +/// A list kept with the session's `printer_source` it came from (`None` for the system's printers). +type Kept = std::sync::Mutex, T)>>; + +/// The last answers `system_printer_list` and `rgb_printers` got in this process. +static LAST_PRINTERS: Kept> = std::sync::Mutex::new(None); +static LAST_RGB_PRINTERS: Kept = std::sync::Mutex::new(None); + +/// The kept list when it came from `source`; a list from other printers is not returned. +fn last(kept: &Kept, source: Option<&PrinterSource>) -> Option { + let kept = kept.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let (from, list) = kept.as_ref()?; + let same = match (from, source) { + (None, None) => true, + (Some(from), Some(source)) => std::sync::Arc::ptr_eq(from, source), + _ => false, + }; + same.then(|| list.clone()) +} + +fn keep(kept: &Kept, source: Option<&PrinterSource>, value: T) { + *kept.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((source.cloned(), value)); +} + +/// Lists the printers; called with the spooler lock held. +fn list_system_printers(s: &Session) -> Vec { + let list = match stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")) { + Some(source) => source(), + None => { + SYSTEM_LISTINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + craft_print::printers() + } + }; + keep(&LAST_PRINTERS, s.printer_source.as_ref(), list.clone()); + list +} + +/// `file.printers`' stand-in: the session's outside the browser, and `None` in the browser, where +/// `file.printers` lists no printers. +fn stand_in_printers(source: Option<&PrinterSource>, browser: bool) -> Option<&PrinterSource> { + source.filter(|_| !browser) +} + +/// Lists the GDI route's printers; called with the spooler lock held. +fn list_rgb_printers(s: &Session) -> RgbPrinters { + let list = match &s.printer_source { + Some(source) => stand_in_rgb_printers(source, cfg!(windows)), + None => { + SYSTEM_LISTINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + craft_print::rgb::printers() + } + }; + keep(&LAST_RGB_PRINTERS, s.printer_source.as_ref(), list.clone()); + list +} + +/// The GDI route's answer from a stand-in: its printers on Windows, and `Unsupported` elsewhere, +/// as `craft_print::rgb::printers` answers there. +fn stand_in_rgb_printers(source: &PrinterSource, windows: bool) -> RgbPrinters { + if !windows { + return Err(craft_print::rgb::PrintError::Unsupported); + } + Ok(source().into_iter().map(|p| craft_print::rgb::Printer { name: p.name, default: p.default }).collect()) +} + +/// The printers `craft-print` spools to, or the session's stand-in for them. +fn system_printer_list(s: &Session) -> Vec { + with_spooler(|| list_system_printers(s)) +} + +/// The printers the GDI route prints to (`craft_print::rgb`), or on Windows the session's stand-in +/// for them. +pub fn rgb_printers(s: &Session) -> RgbPrinters { + with_spooler(|| list_rgb_printers(s)) +} + +/// Lists printers when the spooler is available. While it is busy, returns the cached list for +/// the same source, or `Unsupported` if that source has no cached list. +pub fn rgb_printers_without_waiting(s: &Session) -> RgbPrinters { + try_with_spooler(|| list_rgb_printers(s)) + .or_else(|| last(&LAST_RGB_PRINTERS, s.printer_source.as_ref())) + .unwrap_or(Err(craft_print::rgb::PrintError::Unsupported)) +} + +/// Lists printers for the Print dialog. While the system spooler is busy, returns the cached +/// list for the same source, or an empty list if that source has no cached list. +pub fn printers_without_waiting(s: &mut Session) -> Value { + if stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")).is_none() && system_spooler().is_none() { + return system_printers(s, &json!({})).unwrap_or_default(); + } + let list = try_with_spooler(|| list_system_printers(s)).or_else(|| last(&LAST_PRINTERS, s.printer_source.as_ref())).unwrap_or_default(); + printers_answer(list) +} + +/// `file.printers`' answer for a list from `craft-print`. +fn printers_answer(list: Vec) -> Value { + let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); + let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); + json!({"server": Value::Null, "default": default, "printers": printers}) +} + /// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a /// failure on a route that exists (a wrong printer name, a rejected job). enum SpoolError { @@ -91,7 +261,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - } if let Some(name) = system_spooler() { require_windows_pdf()?; - return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); + return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other); } } match cups_spool(job) { @@ -190,13 +360,10 @@ fn cups_spool(job: &PrintJob) -> std::result::Result { /// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From /// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named /// server and where there is no `lp` (a sandbox). -fn system_printers(_s: &mut Session, p: &Value) -> Result { +fn system_printers(s: &mut Session, p: &Value) -> Result { let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()); - if named.is_none() && system_spooler().is_some() { - let list = craft_print::printers(); - let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); - let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); - return Ok(json!({"server": Value::Null, "default": default, "printers": printers})); + if named.is_none() && (stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")).is_some() || system_spooler().is_some()) { + return Ok(printers_answer(system_printer_list(s))); } #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { @@ -341,8 +508,10 @@ fn pdf_string(s: &str) -> String { } fn deflate(data: &[u8]) -> Vec { - // Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default. - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + // 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 + // than miniz's level 1 was, at about twice its time. + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(3)); let _ = e.write_all(data); e.finish().unwrap_or_default() } @@ -736,7 +905,8 @@ fn with_driver_printable(p: &Value) -> Value { fn driver_printable(p: &Value) -> Option { let printer = printer_name(p).ok()?; - let pg = craft_print::rgb::page(&printer, printer_settings(p).ok()?.as_ref()).ok()?; + let settings = printer_settings(p).ok()?; + let pg = with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())).ok()?; // Only for the paper the driver is set to: a print laid out on another paper keeps the // fixed margin (and `print_windows` refuses it), as before. let (pw, ph) = paper(p, "file.print").ok()?; @@ -905,7 +1075,7 @@ fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { return Ok(name.to_string()); } - craft_print::rgb::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) + with_spooler(craft_print::rgb::default_printer).map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) } /// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any. @@ -958,16 +1128,16 @@ fn settings_for_layout( settings: Option, paper: (f64, f64), ) -> Result<(Option, craft_print::rgb::Page)> { - let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?; + let page = with_spooler(|| craft_print::rgb::page(printer, settings.as_ref())).map_err(other)?; if !turned_paper(page.paper, paper) { return Ok((settings, page)); } let base = match settings { Some(s) => s, - None => craft_print::rgb::default_settings(printer).map_err(other)?, + None => with_spooler(|| craft_print::rgb::default_settings(printer)).map_err(other)?, }; let turned = turned_settings(&base, paper.0 > paper.1)?; - let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?; + let page = with_spooler(|| craft_print::rgb::page(printer, Some(&turned))).map_err(other)?; Ok((Some(turned), page)) } @@ -1027,14 +1197,14 @@ fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Resul color_policy, primitives: &primitives, }; - let done = craft_print::rgb::print(&job, &image, rect).map_err(other)?; + let done = with_spooler(|| craft_print::rgb::print(&job, &image, rect)).map_err(other)?; Ok( json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}), ) } -fn printers(_: &mut Session, _: &Value) -> Result { - match craft_print::rgb::printers() { +fn printers(s: &mut Session, _: &Value) -> Result { + match rgb_printers(s) { Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::>()})), Err(craft_print::rgb::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), Err(e) => Err(other(e)), @@ -1056,11 +1226,11 @@ fn printer_page(_: &mut Session, p: &Value) -> Result { if let Some(landscape) = landscape { let base = match settings { Some(s) => s, - None => craft_print::rgb::default_settings(&printer).map_err(other)?, + None => with_spooler(|| craft_print::rgb::default_settings(&printer)).map_err(other)?, }; settings = Some(turned_settings(&base, landscape)?); } - let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; + let pg = with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())).map_err(other)?; let mut out = json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}); // The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does). if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) { diff --git a/crates/engine/src/cmd/print_cmds/tests.rs b/crates/engine/src/cmd/print_cmds/tests.rs index ab2e104a48..6208b53ec5 100644 --- a/crates/engine/src/cmd/print_cmds/tests.rs +++ b/crates/engine/src/cmd/print_cmds/tests.rs @@ -258,17 +258,50 @@ fn native_rgb_route_preserves_pdf_only_color_and_page_content() { assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false), "noColorManagement")); } +/// Two printers, the second the default, in place of the system spooler's. +fn test_printers() -> PrinterSource { + std::sync::Arc::new(|| { + vec![ + craft_print::Printer { name: "Studio".into(), default: false, two_sided: None, description: String::new() }, + craft_print::Printer { name: "Office".into(), default: true, two_sided: Some(true), description: String::new() }, + ] + }) +} + #[test] fn printer_commands_answer_without_a_document_and_reject_bad_settings() { let mut s = Session::new(); + s.printer_source = Some(test_printers()); let r = s.execute("file.print.printers", json!({})).unwrap(); assert_eq!(r["supported"], cfg!(windows)); - assert!(r["printers"].is_array()); + let listed = if cfg!(windows) { json!([{"name": "Studio", "default": false}, {"name": "Office", "default": true}]) } else { json!([]) }; + assert_eq!(r["printers"], listed); assert!(s.execute("file.print.page", json!({"printer": "Anything", "printerSettings": "zz"})).is_err()); let mut s = session("rgb", 8); assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err()); } +/// The GDI route lists a stand-in's printers only on Windows. Elsewhere it answers `Unsupported` +/// as the real route does, so the Print dialog shows the system's printers and paper controls. +#[test] +fn stand_in_printers_reach_the_gdi_route_on_windows_only() { + let source = test_printers(); + assert_eq!(stand_in_rgb_printers(&source, false), Err(craft_print::rgb::PrintError::Unsupported)); + let listed = stand_in_rgb_printers(&source, true).unwrap(); + assert_eq!(listed.iter().map(|p| (p.name.as_str(), p.default)).collect::>(), [("Studio", false), ("Office", true)]); +} + +/// `file.printers` lists a stand-in's printers everywhere but the browser. In the browser it lists +/// none, as the real command does there. +#[test] +fn stand_in_printers_reach_file_printers_outside_the_browser_only() { + let source = test_printers(); + assert!(stand_in_printers(Some(&source), true).is_none()); + let listed = stand_in_printers(Some(&source), false).map(|source| source()); + assert_eq!(listed.unwrap_or_default().iter().map(|p| (p.name.as_str(), p.default)).collect::>(), [("Studio", false), ("Office", true)]); + assert!(stand_in_printers(None, false).is_none()); +} + /// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every /// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set. fn devmode(size: u16, extra: u16) -> Vec { @@ -676,13 +709,189 @@ fn disabled_printing_never_spools() { #[test] fn printers_lists_the_spooler_destinations_without_a_document() { let mut s = Session::new(); + s.printer_source = Some(test_printers()); let r = s.execute("file.printers", json!({})).unwrap(); - let list = r["printers"].as_array().unwrap(); - assert!(list.iter().all(|p| p["name"].is_string() && p["default"].is_boolean())); - match r["default"].as_str() { - Some(d) => assert!(list.iter().any(|p| p["name"] == d && p["default"] == true)), - None => assert!(list.iter().all(|p| p["default"] == false)), + assert_eq!(r["printers"], json!([{"name": "Studio", "default": false}, {"name": "Office", "default": true}])); + assert_eq!(r["default"], "Office"); + s.printer_source = Some(std::sync::Arc::new(Vec::new)); + let r = s.execute("file.printers", json!({})).unwrap(); + assert_eq!((r["printers"].clone(), r["default"].clone()), (json!([]), Value::Null)); +} + +/// Threads that list printers at the same time enter the spooler one at a time: the stand-in +/// printer list records how many threads were inside it at once. +#[test] +fn printer_listings_enter_the_spooler_one_thread_at_a_time() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + const THREADS: usize = 8; + let inside = Arc::new(AtomicUsize::new(0)); + let most = Arc::new(AtomicUsize::new(0)); + let start = Arc::new(Barrier::new(THREADS)); + let threads: Vec<_> = (0..THREADS) + .map(|i| { + let (inside, most, start) = (Arc::clone(&inside), Arc::clone(&most), Arc::clone(&start)); + std::thread::spawn(move || { + let mut s = Session::new(); + s.printer_source = Some(Arc::new(move || { + let now = inside.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + inside.fetch_sub(1, Ordering::SeqCst); + vec![craft_print::Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] + })); + start.wait(); + for _ in 0..3 { + // `file.print.printers` lists the stand-in on Windows only. + let id = if i % 2 == 0 || !cfg!(windows) { "file.printers" } else { "file.print.printers" }; + assert_eq!(s.execute(id, json!({})).unwrap()["printers"][0]["name"], "Office"); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); } + assert_eq!(most.load(Ordering::SeqCst), 1, "two threads were in the printer listing at once"); +} + +/// A caller that panics inside the spooler leaves it usable for later callers. +#[test] +fn a_panic_inside_the_spooler_does_not_block_later_callers() { + let panicked = std::thread::spawn(|| with_spooler::<()>(|| panic!("a driver call panicked"))).join(); + assert!(panicked.is_err()); + assert_eq!(with_spooler(|| 7), 7); + let mut s = Session::new(); + s.printer_source = Some(test_printers()); + assert_eq!(s.execute("file.printers", json!({})).unwrap()["default"], "Office"); +} + +/// A driver call made while this thread already holds the spooler (a command run from the Print +/// dialog's `try_with_spooler`) runs at once. +#[test] +fn a_thread_holding_the_spooler_does_not_wait_for_itself() { + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.printer_source = Some(test_printers()); + // Inside one outer hold, so no other test takes the lock between the calls. + let answers = with_spooler(|| { + let nested = with_spooler(|| with_spooler(|| 7)); + let tried = try_with_spooler(|| with_spooler(|| try_with_spooler(|| 8))); + let listed = try_with_spooler(|| s.execute("file.printers", json!({})).unwrap()["default"].clone()); + (nested, tried, listed) + }); + done.send(answers).unwrap(); + }); + // Bounded, so a thread that waits for itself fails the test rather than hanging it. + let answers = finished.recv_timeout(std::time::Duration::from_secs(30)).expect("a thread holding the spooler waited for itself"); + assert_eq!(answers, (7, Some(Some(8)), Some(json!("Office")))); +} + +/// While another thread is in the spooler (a print job being handed over, Print Settings…), the +/// Print dialog's listings return at once with the last lists this process got. +#[test] +fn listings_without_waiting_return_the_last_lists_while_the_spooler_is_busy() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; + let asked = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&asked); + let mut s = Session::new(); + s.printer_source = Some(Arc::new(move || { + counted.fetch_add(1, Ordering::SeqCst); + vec![craft_print::Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] + })); + let (held_tx, held) = mpsc::channel(); + let (release, released_rx) = mpsc::channel::<()>(); + let released = Arc::new(AtomicBool::new(false)); + let holder = { + let released = Arc::clone(&released); + let mut lister = Session::new(); + lister.printer_source = s.printer_source.clone(); + std::thread::spawn(move || { + with_spooler(|| { + // Lists this stand-in's printers once with the lock held, so another test's listing + // cannot replace the last lists before the listings below. + held_tx.send((list_system_printers(&lister), list_rgb_printers(&lister))).unwrap(); + // Bounded, so a listing that waits fails the test rather than hanging it. + let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5)); + released.store(true, Ordering::SeqCst); + }); + }) + }; + let (kept, kept_rgb) = held.recv().unwrap(); + let before = asked.load(Ordering::SeqCst); + let listed = printers_without_waiting(&mut s); + let listed_rgb = rgb_printers_without_waiting(&s); + assert!(!released.load(Ordering::SeqCst), "the listing waited for the spooler"); + release.send(()).unwrap(); + holder.join().unwrap(); + assert_eq!(asked.load(Ordering::SeqCst), before, "a printer list was asked for while the spooler was busy"); + assert_eq!(listed, printers_answer(kept)); + assert_eq!(listed_rgb, kept_rgb); +} + +#[test] +fn listings_without_waiting_skip_the_lists_of_another_stand_in() { + uncached_listings_do_not_wait(true); +} + +#[test] +fn listings_without_waiting_return_empty_when_no_list_was_cached() { + uncached_listings_do_not_wait(false); +} + +fn uncached_listings_do_not_wait(cache_other: bool) { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; + let asked = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&asked); + let mut s = Session::new(); + s.printer_source = Some(Arc::new(move || { + counted.fetch_add(1, Ordering::SeqCst); + vec![craft_print::Printer { name: "Proofing".into(), default: true, two_sided: None, description: String::new() }] + })); + let (held_tx, held) = mpsc::channel(); + let (release, released_rx) = mpsc::channel(); + let released = Arc::new(AtomicBool::new(false)); + let done = Arc::clone(&released); + let holder = std::thread::spawn(move || { + with_spooler(|| { + if cache_other { + let mut other = Session::new(); + other.printer_source = Some(test_printers()); + list_system_printers(&other); + let _ = list_rgb_printers(&other); + } else { + *LAST_PRINTERS.lock().unwrap() = None; + *LAST_RGB_PRINTERS.lock().unwrap() = None; + } + held_tx.send(()).unwrap(); + let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5)); + done.store(true, Ordering::SeqCst); + }); + }); + held.recv().unwrap(); + let listed = printers_without_waiting(&mut s); + let listed_rgb = rgb_printers_without_waiting(&s); + let waited = released.load(Ordering::SeqCst); + let _ = release.send(()); + holder.join().unwrap(); + assert!(!waited, "an uncached listing waited for the spooler"); + assert_eq!(asked.load(Ordering::SeqCst), 0); + assert_eq!(listed, printers_answer(Vec::new())); + assert_eq!(listed_rgb, Err(craft_print::rgb::PrintError::Unsupported)); + with_spooler(|| { + let listed = printers_without_waiting(&mut s); + let listed_rgb = rgb_printers_without_waiting(&s); + assert_eq!(listed["printers"], json!([{"name": "Proofing", "default": true}])); + let names = listed_rgb.map(|list| list.into_iter().map(|p| p.name).collect::>()); + if cfg!(windows) { + assert_eq!(names, Ok(vec!["Proofing".to_string()])); + } else { + assert_eq!(names, Err(craft_print::rgb::PrintError::Unsupported)); + } + }); } /// Spooling tests run in a child process: `disable_printing` is process-wide and a test above diff --git a/crates/engine/src/cmd/render_cmds.rs b/crates/engine/src/cmd/render_cmds.rs index c6e4318e3d..cc27304fdc 100644 --- a/crates/engine/src/cmd/render_cmds.rs +++ b/crates/engine/src/cmd/render_cmds.rs @@ -109,7 +109,7 @@ fn draw(s: &mut Session, label: &str, p: &Value, prims: Vec, extra: Value) let raster = id.and_then(|i| doc.layer(i)).is_some_and(|l| matches!(l.content, LayerContent::Raster(_))); let (id, created) = if want_new || !raster { let l = Layer::new(label.trim_end_matches('…'), LayerContent::Raster(Surface::new(doc.pixel_format()))); - let nid = doc.insert_above(*active, l); + let nid = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable); *active = Some(nid); (nid, true) } else { diff --git a/crates/engine/src/cmd/retouch_cmds/art_history.rs b/crates/engine/src/cmd/retouch_cmds/art_history.rs index ab66a8f15c..0339c88263 100644 --- a/crates/engine/src/cmd/retouch_cmds/art_history.rs +++ b/crates/engine/src/cmd/retouch_cmds/art_history.rs @@ -7,7 +7,7 @@ use photocraft_paint::MAX_BRUSH_SIZE; use photocraft_paint::retouch::{Region, alpha_index}; use serde_json::{Value, json}; -use super::*; +use super::{EngineError, Result, Session, bad, blend_param, damage_json, parse_brush, run_stroke}; const CMD: &str = "paint.artHistoryBrush"; const MAX_POINTS: usize = 4096; diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 5e5ac27104..1e4ae8ca0e 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -1,6 +1,10 @@ use super::heal::history_source; use super::sampling::{SampleLayers, sample, sample_layers}; -use super::*; +use super::{ + BlendMode, BrushSettings, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, SampleType, Session, Stroke, StrokePoint, Surface, Value, + alpha_index, apply_coverage, bad, blend_from_str, coverage, damage_json, flag, has_pixel_layer, json, parse_brush, pattern_stamp, periodic, point, + reject_huge_stroke, run_stroke, to_rgba, +}; /// Where a clone stroke samples: `offset` / `source` params, else the active Clone Source slot, /// with the slot's (or the call's) scale, rotation and flips (see `presets::clone_source`). diff --git a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs index efa63e20b2..6e01926e3f 100644 --- a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs @@ -34,7 +34,10 @@ use photocraft_algo::content_aware::{FillOptions, color_amount, color_level, fil use photocraft_algo::transform::{Homography, Interp}; use super::patch::{coverage, offset, selection_area, target_layer}; -use super::*; +use super::{ + Document, EngineError, LayerContent, LayerId, Rect, Region, Result, SampleLayers, Session, Surface, Value, alpha_index, bad, clamp_samples, + composite_region, damage_json, flag, has_pixel_layer, json, string, targets_pixels, +}; const CMD: &str = "paint.contentAwareMove"; diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 818c27a6f1..7ff5a600e6 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -1,5 +1,9 @@ use super::sampling::{AllLayers, sample_all_layers}; -use super::*; +use super::{ + BrushSettings, Document, EngineError, Footprint, IndexedParallelIterator, LayerId, ParallelIterator, ParallelSliceMut, PixelFormat, Rect, Region, Result, + Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, alpha_index, apply_dab_stroke, bad, damage_json, dodge_burn, flag, from_rgba_into, + has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, +}; // --------------------------------------------------------------------------------------------- // Toning (Dodge / Burn / Sponge) and focus (Blur / Sharpen), Smudge diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index 79e42723e6..a260443caf 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -1,6 +1,9 @@ use super::clone::{blend_param, clamp_samples, clone_mapping, clone_result, clone_sample}; use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers}; -use super::*; +use super::{ + BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad, + coverage, damage_json, flag, inpaint, json, parse_brush, poisson, reject_huge_stroke, run_stroke, string, +}; /// Grow a stroke's coverage map by `m` pixels of zero coverage (a Dirichlet boundary for the solve). fn pad_coverage(bounds: Rect, cov: &[f32], m: i32) -> (Rect, Vec) { diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index f3cb4765a2..557c173b4b 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -17,7 +17,10 @@ //! (3: 32 of 42 levels, 31 of 102; 10: 35 of 42, 95 of 102). It runs as a background job, like //! the move. -use super::*; +use super::{ + BlendMode, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Surface, Value, apply_coverage, bad, clamp_samples, damage_json, + flag, has_pixel_layer, heal_region, json, point, poisson, run_stroke, string, targets_pixels, +}; const CMD: &str = "paint.patch"; diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index f31542fb5a..242579198e 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -1,6 +1,6 @@ //! Bounded reads from the repeating document tile used by Pattern Preview. -use super::*; +use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, 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. diff --git a/crates/engine/src/cmd/retouch_cmds/remove.rs b/crates/engine/src/cmd/retouch_cmds/remove.rs index eb9ecd1028..a7283d390b 100644 --- a/crates/engine/src/cmd/retouch_cmds/remove.rs +++ b/crates/engine/src/cmd/retouch_cmds/remove.rs @@ -16,7 +16,10 @@ use super::content_aware_move::{Target, replace}; use super::patch::coverage; -use super::*; +use super::{ + Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, SampleLayers, Session, Stroke, Surface, Value, alpha_index, bad, clamp_samples, + composite_region, damage_json, flag, json, parse_brush, reject_huge_stroke, targets_pixels, +}; use photocraft_doc::Layer; const CMD: &str = "paint.remove"; @@ -216,7 +219,8 @@ fn fill_to_new_layer(doc: &mut Document, id: Option, out: &Region, hole } let mut l = Layer::raster(doc.next_layer_name("Layer"), fmt); crate::pixels_mut(&mut l)?.write_region(out.rect, &data); - let nid = doc.insert_above(id, l); + // Above the sampled layer and in its clipping mask, like Layer via Copy. + let nid = crate::layer_place::insert_new(doc, id, l, crate::layer_place::Place::Above); Ok((out.rect, nid)) } diff --git a/crates/engine/src/cmd/retouch_cmds/sampling.rs b/crates/engine/src/cmd/retouch_cmds/sampling.rs index 31ab9ae99c..253f6648ad 100644 --- a/crates/engine/src/cmd/retouch_cmds/sampling.rs +++ b/crates/engine/src/cmd/retouch_cmds/sampling.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Footprint, LayerId, PixelFormat, Rect, Region, Result, Surface, Value, bad, flag, from_rgba_into, string, targets_pixels}; // --------------------------------------------------------------------------------------------- // Sampling sources diff --git a/crates/engine/src/cmd/smart_cmds/contents.rs b/crates/engine/src/cmd/smart_cmds/contents.rs index 393b03e8ac..07a009bf0e 100644 --- a/crates/engine/src/cmd/smart_cmds/contents.rs +++ b/crates/engine/src/cmd/smart_cmds/contents.rs @@ -2,7 +2,10 @@ use super::filters::{smart, smart_mut}; use super::placing::detach_psd; use super::rendering::{refresh, refresh_or_fail, unapplied_filters}; use super::source::{authorized_source_bytes, source_bytes}; -use super::*; +use super::{ + Arc, DocId, Document, EngineError, LayerContent, LayerId, Metadata, Result, Session, SmartContentsId, SmartLink, SmartSource, Value, bad, base_name, + decode_source, encode_source, json, layer_param, other, +}; pub(super) fn path_param<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> { p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad(cmd, "pass `path`")) diff --git a/crates/engine/src/cmd/smart_cmds/filters.rs b/crates/engine/src/cmd/smart_cmds/filters.rs index e06cb61a48..3009ecfbc0 100644 --- a/crates/engine/src/cmd/smart_cmds/filters.rs +++ b/crates/engine/src/cmd/smart_cmds/filters.rs @@ -1,6 +1,9 @@ use super::rendering::{refresh_or_fail, refresh_or_fail_in}; use super::source::source_bytes; -use super::*; +use super::{ + BlendMode, Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, SmartObject, Surface, Value, bad, blend_from_str, json, + layer_param, other, +}; pub(super) fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { match &doc.layer(id).ok_or(EngineError::NoLayer(id))?.content { diff --git a/crates/engine/src/cmd/smart_cmds/placing.rs b/crates/engine/src/cmd/smart_cmds/placing.rs index 857cb90338..e0472e844d 100644 --- a/crates/engine/src/cmd/smart_cmds/placing.rs +++ b/crates/engine/src/cmd/smart_cmds/placing.rs @@ -1,5 +1,8 @@ use super::source::{SourceImage, buffer_to_surface, cache_key, cache_put, source_bytes}; -use super::*; +use super::{ + Affine, Arc, BlendMode, Document, EngineError, Homography, Layer, LayerContent, LayerId, PLACED_KEYS, Rect, Result, Session, Size, SmartContentsId, + SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, translate_surface, +}; /// Rounds float noise off a composed transform (scale 0.1 then 10 should be exactly 1), so /// repeated transforms land back on exact, lossless placements. diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index b686cf3282..a5dd8e8f1b 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -1,6 +1,9 @@ use super::filters::smart; use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface, svg_source_image, vector_scale}; -use super::*; +use super::{ + Affine, Arc, BlendMode, Document, EngineError, Homography, JobCtx, Layer, LayerContent, LayerId, PixelFormat, Rect, Result, SmartFilter, SmartObject, + SmartSource, Stage, StageKey, Surface, Value, decode_source, other, stack_cache, +}; /// Layer › Smart Objects › Stack Mode: the source's layers (or, when it holds a single group, the /// group's layers) combined per pixel with `mode`. Cached like [`source_image`], keyed by mode. diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index 0e0aaee477..d7d895b28d 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, ColorMode, Document, Layer, LayerContent, LayerId, Metadata, Mutex, PixelFormat, Rect, Result, SampleType, Session, SmartObject, SmartSource, Surface, + base_name, decode_source, json, other, +}; /// A linked file's bytes; `None` when it is missing or its path is not followed /// ([`crate::doc_path::resolve`]). diff --git a/crates/engine/src/cmd/smart_cmds/unpack.rs b/crates/engine/src/cmd/smart_cmds/unpack.rs index 0fdf3ac3fa..af7510da8d 100644 --- a/crates/engine/src/cmd/smart_cmds/unpack.rs +++ b/crates/engine/src/cmd/smart_cmds/unpack.rs @@ -161,7 +161,7 @@ fn unpacked(so: &Layer, mut layers: Vec) -> Layer { g.id = LayerId::fresh(); g.psd_id = None; g.blend = blend; - g.content = LayerContent::Group(Group { children: layers, expanded: true, artboard: None }); + g.content = LayerContent::Group(Group { children: layers, expanded: true, artboard: None, frame_stroke: None }); detach_psd(&mut g); g } diff --git a/crates/engine/src/cmd/stamp_cmds.rs b/crates/engine/src/cmd/stamp_cmds.rs index 76af35627a..b5c6a21120 100644 --- a/crates/engine/src/cmd/stamp_cmds.rs +++ b/crates/engine/src/cmd/stamp_cmds.rs @@ -74,7 +74,7 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { return Ok(empty); } let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?; - let id = doc.insert_above(*active, layer); + let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::Above); *active = Some(id); Ok(id) })?; @@ -156,7 +156,7 @@ fn stamp_selected(s: &mut Session, sel: &[LayerId]) -> Result { } let name = format!("{} (merged)", doc.layer(top).map_or("Layer", |l| l.name.as_str())); let layer = stamp_layer(&solo, name)?; - let id = doc.insert_above(Some(top), layer); + let id = crate::layer_place::insert_new(doc, Some(top), layer, crate::layer_place::Place::Above); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/type_caret_cmds.rs b/crates/engine/src/cmd/type_caret_cmds.rs index 3b9b244f70..75ca3e0730 100644 --- a/crates/engine/src/cmd/type_caret_cmds.rs +++ b/crates/engine/src/cmd/type_caret_cmds.rs @@ -320,6 +320,13 @@ mod tests { #[test] fn navigate_words_lines_edges_and_keeps_a_column() { + check_word_moves(); + check_newline_moves(); + check_vertical_column_moves(); + check_navigation_queries_are_read_only(); + } + + fn check_word_moves() { let mut s = session(); let id = create(&mut s, json!({"x": 20, "y": 60, "text": "hello big world", "size": 18})); assert_eq!(go(&mut s, id, 0, "wordNext", None), 5); @@ -334,7 +341,10 @@ mod tests { assert_eq!(go(&mut s, id, 4, "end", None), 15); assert_eq!(go(&mut s, id, 4, "lineStart", None), 0); assert_eq!(go(&mut s, id, 4, "lineEnd", None), 15); + } + fn check_newline_moves() { + let mut s = session(); // A newline is not part of either word; line edges agree with type.info. let id = create(&mut s, json!({"x": 20, "y": 120, "text": "hello\nworld", "size": 18})); assert_eq!(go(&mut s, id, 0, "wordNext", None), 5); @@ -353,7 +363,10 @@ mod tests { assert!((l1s..=l1e).contains(&next), "lineNext {next} not on {l1s}..={l1e}"); assert_eq!(go(&mut s, id, 0, "linePrev", None), 0); assert_eq!(go(&mut s, id, l1e as usize, "lineNext", None), "hello\nworld".chars().count() as u64); + } + fn check_vertical_column_moves() { + let mut s = session(); // Repeated line moves keep the document x of the original column. let id = create(&mut s, json!({"x": 24, "y": 200, "text": "WWWWWW\nI", "size": 28})); let info = s.execute("type.info", json!({"layer": id})).unwrap(); @@ -366,7 +379,13 @@ mod tests { assert_eq!(go(&mut s, id, end0 as usize, "lineNext", Some(x)), start1); assert_eq!(go(&mut s, id, end0 as usize, "lineNext", None), end1); assert_eq!(go(&mut s, id, start1 as usize, "linePrev", Some(x)), lines[0]["start"].as_u64().unwrap()); + } + fn check_navigation_queries_are_read_only() { + let mut s = session(); + let id = create(&mut s, json!({"x": 24, "y": 200, "text": "WWWWWW\nI", "size": 28})); + let caret0 = s.execute("type.caret", json!({"layer": id, "index": 0})).unwrap(); + let x = caret0["segment"][0][0].as_f64().unwrap(); // Queries do not record history or a journal entry. let steps = s.active().unwrap().history.entries().len(); let rev = s.active().unwrap().revision; diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 67fc34be60..1e103cf89f 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -244,6 +244,13 @@ fn step_char_metrics(t: &mut TextLayer, a: usize, b: usize, p: &Value) -> bool { /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { + let mut any = apply_font_props(s, p); + any |= apply_char_values(s, p); + any |= apply_char_features(s, p); + any +} + +fn apply_font_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; let mut hit = |b: bool| any |= b; let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some()); @@ -309,6 +316,12 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { if font_key && s.postscript_name.is_none() { s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic); } + any +} + +fn apply_char_values(s: &mut CharStyle, p: &Value) -> bool { + let mut any = false; + let mut hit = |b: bool| any |= b; if let Some(v) = f32p(p, "size") { s.size_pt = v.max(0.01); hit(true); @@ -396,6 +409,12 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { hit(true); } } + any +} + +fn apply_char_features(s: &mut CharStyle, p: &Value) -> bool { + let mut any = false; + let mut hit = |b: bool| any |= b; if let Some(Value::Object(m)) = p.get("features") { for (tag, v) in m { let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1); @@ -906,7 +925,7 @@ pub fn specs() -> Vec { let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| layer_name(&t.text)); let id = s.edit("New Type Layer", |doc, active| { refresh(doc, &mut t); - let id = doc.insert_above(*active, Layer::new(name, LayerContent::Text(t))); + let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/type_extra_cmds.rs b/crates/engine/src/cmd/type_extra_cmds.rs index 99538edb70..d8c11a52d6 100644 --- a/crates/engine/src/cmd/type_extra_cmds.rs +++ b/crates/engine/src/cmd/type_extra_cmds.rs @@ -415,6 +415,38 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "start": start, "flip": flip})) } +/// Type on a path's own path in document space (#9114): what the Direct Selection tool shows and +/// edits, as Photoshop's Type Path. `None` unless `t` is type on a path. +pub fn type_path(t: &TextLayer) -> Option { + let TextShape::Path { path, .. } = &t.shape else { return None }; + let map = |q: Point| t.transform.apply(q); + let knots = path.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + Some(Path::new(vec![Subpath { closed: path.closed, knots, op: path.op }])) +} + +/// Puts an edited [`type_path`] back into `t` (its text space), keeping where the type starts and +/// which side it runs on. It must still be one subpath with two or more points. +pub(crate) fn set_type_path(t: &mut TextLayer, edited: &Path) -> Result<()> { + let TextShape::Path { start, flip, .. } = &t.shape else { return Err(EngineError::Other("the type layer is not on a path".into())) }; + let (start, flip) = (*start, *flip); + let [sub] = edited.subpaths.as_slice() else { return Err(EngineError::Other("type on a path follows one path".into())) }; + if sub.knots.len() < 2 { + return Err(EngineError::Other("type on a path needs a path of two or more points".into())); + } + if sub.knots.len() > MAX_TYPE_PATH_KNOTS { + return Err(EngineError::Other("the type path has too many anchor points".into())); + } + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let knots: Vec = + sub.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + if !knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { + return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); + } + t.shape = TextShape::Path { path: Subpath { closed: sub.closed, knots, op: sub.op }, start, flip }; + Ok(()) +} + fn is_path_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Path { .. } | TextShape::Area { .. } => Ok(()), diff --git a/crates/engine/src/cmd/type_extra_cmds/tests.rs b/crates/engine/src/cmd/type_extra_cmds/tests.rs index d1bc3c39f1..8ae504cfa1 100644 --- a/crates/engine/src/cmd/type_extra_cmds/tests.rs +++ b/crates/engine/src/cmd/type_extra_cmds/tests.rs @@ -314,6 +314,80 @@ fn type_attaches_to_a_path_and_releases_it() { assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); } +/// #9114: type on a path follows its own path when the path is edited (Direct Selection's +/// `path.*` commands with `"name": "layer"`), as Photoshop's Type Path does; the Paths list names +/// it, an edit is one undo step, and an edit that leaves no path is refused. +#[test] +fn type_follows_its_path_when_the_path_is_edited() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + s.execute("type.attachToPath", json!({"start": 4})).unwrap(); + // In document space, the type path is where the work path was. + let path = crate::type_extra_cmds::type_path(text(&s, id)).unwrap(); + let ends: Vec<(f64, f64)> = path.subpaths[0].knots.iter().map(|k| (k.anchor.x, k.anchor.y)).collect(); + assert!(ends.iter().zip([(150.0, 10.0), (150.0, 110.0)]).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{ends:?}"); + let listed = s.execute("path.list", json!({})).unwrap(); + assert_eq!(listed["typePath"]["kind"], "typePath", "{listed}"); + // Swing the path's end to the right: the type now runs down and to the right. + let before = ink(&s, id); + let r = s.execute("path.moveAnchors", json!({"name": "layer", "layer": id.0, "anchors": [[0, 1]], "move": [120, 0]})).unwrap(); + assert_eq!(r["name"], "layer"); + let after = ink(&s, id); + assert!(after.width() > before.width() + 20, "{before:?} → {after:?}"); + assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0), "where the type starts is kept"); + // The work path it came from is untouched; Undo puts the type path back. + assert_eq!(s.active().unwrap().doc.work_path.as_ref().unwrap().subpaths[0].knots[1].anchor.x, 150.0); + assert!(s.undo()); + assert_eq!(ink(&s, id), before); + // An edit that would leave fewer than two points is refused and changes nothing. + assert!(s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id.0, "subpath": 0, "knot": 0})).is_err()); + assert_eq!(ink(&s, id), before); +} + +/// #9114: a type-on-a-path layer's vector mask is still what `"name": "layer"` edits, as on main +/// and as `path.info` reads it; `"typePath": true` edits the Type Path; `path.list` reports both. +#[test] +fn a_vector_mask_on_type_on_a_path_is_edited_not_the_type_path() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + s.execute("type.attachToPath", json!({})).unwrap(); + let rect = json!({"subpaths": [{"closed": true, "knots": [[10, 10], [200, 10], [200, 100], [10, 100]]}]}); + s.execute("layer.vectorMask.add", json!({"layer": id.0, "path": rect})).unwrap(); + let listed = s.execute("path.list", json!({})).unwrap(); + assert_eq!((listed["layerPath"]["kind"].as_str(), listed["typePath"]["kind"].as_str()), (Some("vectorMask"), Some("typePath")), "{listed}"); + let type_path = |s: &Session| crate::type_extra_cmds::type_path(text(s, id)).unwrap(); + let before = type_path(&s); + let mask = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path.subpaths[0].knots[0].anchor; + s.execute("path.moveAnchors", json!({"name": "layer", "layer": id.0, "anchors": [[0, 0]], "move": [10, 0]})).unwrap(); + assert_eq!(mask(&s), photocraft_geom::Point::new(20.0, 10.0), "the mask's corner moved"); + assert_eq!(type_path(&s), before, "the Type Path stays"); + s.execute("path.moveAnchors", json!({"name": "layer", "layer": id.0, "typePath": true, "anchors": [[0, 1]], "move": [20, 0]})).unwrap(); + assert!((type_path(&s).subpaths[0].knots[1].anchor.x - before.subpaths[0].knots[1].anchor.x - 20.0).abs() < 1e-6, "the Type Path moved"); + assert_eq!(mask(&s), photocraft_geom::Point::new(20.0, 10.0), "the mask stays"); +} + +/// An edit can't grow the Type Path past the cap `type.attachToPath` keeps. +#[test] +fn an_edited_type_path_keeps_the_knot_cap() { + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + let knots: Vec<(f64, f64)> = (0..=MAX_TYPE_PATH_KNOTS).map(|i| (i as f64 * 0.001, 50.0)).collect(); + let mut t = text(&s, id).clone(); + t.shape = TextShape::Path { path: Subpath::polyline(&knots[..2]), start: 0.0, flip: false }; + assert!(set_type_path(&mut t, &Path::new(vec![Subpath::polyline(&knots[..MAX_TYPE_PATH_KNOTS])])).is_ok()); + assert!(set_type_path(&mut t, &Path::new(vec![Subpath::polyline(&knots)])).is_err()); +} + /// The Type tool's click on a path: `"at"` starts the type where the path passes nearest the /// click, and the paragraph alignment is kept about that point, as in Photoshop. #[test] diff --git a/crates/engine/src/cmd/type_styles_cmds/tests.rs b/crates/engine/src/cmd/type_styles_cmds/tests.rs index 0a5761d58b..6f6f735ff0 100644 --- a/crates/engine/src/cmd/type_styles_cmds/tests.rs +++ b/crates/engine/src/cmd/type_styles_cmds/tests.rs @@ -243,3 +243,14 @@ fn preview_options_validates_without_mutating_source() { assert_ne!(shown.text_styles, before); assert_eq!(doc.text_styles, before); } + +#[test] +fn style_options_take_a_family_with_the_style_picked_under_it() { + // The font menu's ">" picks a family in one of its styles: Style Options sends both. + let (mut s, _) = session("Hello"); + let cs = s.execute("type.characterStyle.new", json!({"attrs": {"size": 20}, "fromSelection": false})).unwrap()["id"].as_u64().unwrap(); + s.execute("type.characterStyle.set", json!({"id": cs, "attrs": {"font": "Arial", "fontStyle": "Bold Italic"}})).unwrap(); + let attrs = s.execute("type.characterStyle.list", json!({})).unwrap()["styles"][1]["attrs"].clone(); + assert_eq!(attrs["font_family"], "Arial", "{attrs}"); + assert_eq!(attrs["font_style"], "Bold Italic", "{attrs}"); +} diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 7f3753e24e..5a53d5ae07 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -132,7 +132,7 @@ pub fn specs() -> Vec { label: "List Paths", menu: &[], shortcut: None, - params: r##"{} → {paths:[{name,knots,subpaths}],workPath,clippingPath,layerPath (active layer's shape path / vector mask)}"##, + params: r##"{} → {paths:[{name,knots,subpaths}],workPath,clippingPath,layerPath (active layer's shape path / vector mask),typePath (active type layer's Type Path, if it is on a path)}"##, enabled: has_doc, run: |s, _| paths_list(s), journal: false, diff --git a/crates/engine/src/cmd/vector_cmds/params.rs b/crates/engine/src/cmd/vector_cmds/params.rs index 9661877409..6deb13186a 100644 --- a/crates/engine/src/cmd/vector_cmds/params.rs +++ b/crates/engine/src/cmd/vector_cmds/params.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color, EngineError, Fill, FillRule, GradientStyle, Knot, LayerId, LineCap, LineJoin, LiveShape, Path, PathOp, Point, Result, Session, ShapeStroke, + StrokeAlign, Subpath, Value, bad, json, +}; pub(crate) fn layer_id(s: &Session, p: &Value) -> Result { match p.get("layer").and_then(Value::as_u64) { diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index c737ea8d69..167cb9d2a6 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -1,6 +1,9 @@ use super::params::{color, f64p, layer_id, op_from, parse_path, path_json}; use super::shape::{map_path, map_shape, transform_shape, with_shape}; -use super::*; +use super::{ + Affine, BlendMode, ClippingPath, Color, Document, EngineError, LayerContent, LayerId, NamedPath, Path, Rect, Result, SelectionMode, Session, Value, bad, + blend_from_str, json, sel, vector, +}; // --------------------------------------------------------------------------- // Document paths @@ -49,7 +52,8 @@ pub(super) fn paths_list(s: &Session) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let doc = &d.doc; let knots = |p: &Path| p.subpaths.iter().map(|s| s.knots.len()).sum::(); - let layer = d.active_layer.and_then(|id| doc.layer(id)).and_then(|l| match &l.content { + let active = d.active_layer.and_then(|id| doc.layer(id)); + let layer = active.and_then(|l| match &l.content { LayerContent::Shape(sh) => Some(json!({ "kind": "shape", "layer": l.id.0, "name": format!("{} Shape Path", l.name), "knots": knots(&sh.path) })), _ => { l.vector_mask.as_ref().map(|v| json!({ "kind": "vectorMask", "layer": l.id.0, "name": format!("{} Vector Mask", l.name), "knots": knots(&v.path) })) @@ -60,6 +64,12 @@ pub(super) fn paths_list(s: &Session) -> Result { "workPath": doc.work_path.as_ref().map(|p| json!({ "knots": knots(p), "subpaths": p.subpaths.len() })), "clippingPath": doc.clipping_path.as_ref().map(|c| json!({ "name": c.name, "flatness": c.flatness })), "layerPath": layer, + // Type on a path: Photoshop's Type Path, which Direct Selection edits (#9114). + "typePath": active.and_then(|l| match &l.content { + LayerContent::Text(t) => crate::type_extra_cmds::type_path(t) + .map(|p| json!({ "kind": "typePath", "layer": l.id.0, "name": format!("{} Type Path", l.name), "knots": knots(&p) })), + _ => None, + }), })) } diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 70af631e91..789fd8f40b 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -2,7 +2,10 @@ use super::params::{ f64p, fill_json, layer_id, live_from_params, live_json, nums, op_from, parse_fill, parse_path, parse_stroke, path_json, radii, stroke_json, }; use super::vector_mask::with; -use super::*; +use super::{ + Affine, Color, Document, EngineError, Fill, FillRule, Layer, LayerContent, LayerId, LiveShape, Path, PathOp, Point, Result, SHAPE_BLOCKS, Session, + ShapeLayer, Value, bad, json, sel, vector, +}; fn active_shape(s: &Session) -> std::result::Result<&ShapeLayer, String> { let d = s.active().ok_or("no document open")?; @@ -338,7 +341,7 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { let id = s.edit("New Shape Layer", |doc, active| { refresh_shape(doc, &mut sh); let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind))); - let id = doc.insert_above(*active, Layer::new(name, LayerContent::Shape(sh))); + let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/cmd/vector_cmds/vector_mask.rs b/crates/engine/src/cmd/vector_cmds/vector_mask.rs index c21b214d81..34abf1eab0 100644 --- a/crates/engine/src/cmd/vector_cmds/vector_mask.rs +++ b/crates/engine/src/cmd/vector_cmds/vector_mask.rs @@ -1,7 +1,7 @@ use super::params::{layer_id, parse_path, path_json}; use super::paths::{is_work, resolve_path}; use super::shape::with_shape; -use super::*; +use super::{EngineError, LayerContent, LayerId, Path, Result, Session, Value, VectorMask, bad, json, vector}; // --------------------------------------------------------------------------- // Vector masks diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 9917522d06..3aec55b837 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -127,7 +127,7 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result { let name = doc.next_layer_name("Video Layer"); let mut l = Layer::raster(name, fmt); l.video = Some(VideoData::new(frames, fps)); - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable); *active = Some(id); sync(doc); Ok(id) @@ -366,8 +366,13 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { let mut v = VideoData::new(frames, fps); v.original_frames = Some(v.frames.clone()); v.source = photocraft_doc::VideoSource::File { path: path.clone() }; + // The first frame is the layer's content before it is placed: an active artboard takes + // the layer only when its content is on the board, as `frames_to_layers` does. + if let (Some(first), LayerContent::Raster(surf)) = (v.frames.first(), &mut l.content) { + *surf = first.clone(); + } l.video = Some(v); - let id = doc.insert_above(*active, l); + let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable); *active = Some(id); sync(doc); Ok(id) @@ -438,12 +443,14 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { let n = frames.len(); s.edit("Video Frames to Layers", |doc, active| { let mut above = *active; + let mut place = crate::layer_place::Place::AutoEditable; for (i, f) in frames.into_iter().enumerate() { let mut l = Layer::raster(format!("Frame {}", i + 1), fmt); if let LayerContent::Raster(surf) = &mut l.content { *surf = f; } - above = Some(doc.insert_above(above, l)); + above = Some(crate::layer_place::insert_new(doc, above, l, place)); + place = crate::layer_place::Place::Above; } *active = above; Ok(()) diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 3560c5f2c3..7e8c05a9b4 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -83,6 +83,21 @@ fn new_video_layer_from_image_sequence() { assert!(s.active().unwrap().doc.timeline.as_ref().unwrap().duration >= 3); } +/// CodeRabbit on #9775: footage that isn't on the active artboard stays out of it, as a placed +/// image does; the board would clip it. Before, the layer was placed while still empty. +#[test] +fn footage_off_the_active_artboard_stays_out_of_it() { + let dir = tmpdir("board"); + write_png(&format!("{dir}/f01.png"), 16, 12, "#ff0000"); + write_png(&format!("{dir}/f02.png"), 16, 12, "#00ff00"); + let mut s = session(); + let board = s.execute("layer.new.artboard", json!({"rect": [0, 0, 4, 4]})).unwrap(); + assert!(board["layer"].as_u64().is_some(), "{board}"); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.artboard_of(id), None, "the footage is centred off the board"); +} + #[test] fn frames_to_layers_and_render_video() { let dir = tmpdir("ftl"); diff --git a/crates/engine/src/cmd/vp_cmds.rs b/crates/engine/src/cmd/vp_cmds.rs index eb713d9141..1a3ac8e0ca 100644 --- a/crates/engine/src/cmd/vp_cmds.rs +++ b/crates/engine/src/cmd/vp_cmds.rs @@ -252,7 +252,7 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { let on = layer.or(*active); let id = if new_layer { let l = Layer::raster(doc.next_layer_name("Vanishing Point"), doc.pixel_format()); - let nid = doc.insert_above(on, l); + let nid = crate::layer_place::insert_new(doc, on, l, crate::layer_place::Place::AutoEditable); *active = Some(nid); nid } else { diff --git a/crates/engine/src/cmd/web_cmds.rs b/crates/engine/src/cmd/web_cmds.rs index b28e7a94fb..470edd3067 100644 --- a/crates/engine/src/cmd/web_cmds.rs +++ b/crates/engine/src/cmd/web_cmds.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } - /// Generate ▸ Image Assets needs Preferences ▸ Plug-ins ▸ Generator enabled, as in + /// Generate ▸ Image Assets needs Preferences ▸ Plug-ins ▸ Enable Generator, as in /// Photoshop (where "Enable Generator" is unchecked until turned on). fn generator_enabled(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; diff --git a/crates/engine/src/cmd/web_cmds/assets.rs b/crates/engine/src/cmd/web_cmds/assets.rs index 23f2da990c..ff265bb4d6 100644 --- a/crates/engine/src/cmd/web_cmds/assets.rs +++ b/crates/engine/src/cmd/web_cmds/assets.rs @@ -1,6 +1,6 @@ use super::optimizer::{optimize, web_document}; use super::settings::{WebFormat, WebMetadata, WebSettings}; -use super::*; +use super::{Dither, DocId, Document, EngineError, LayerContent, LayerId, Result, Session, Value, is_windows_device_name, join, json, other, stem, write_file}; // ---------- Generate › Image Assets ---------- diff --git a/crates/engine/src/cmd/web_cmds/optimizer.rs b/crates/engine/src/cmd/web_cmds/optimizer.rs index 98116d7d37..14e5fd311d 100644 --- a/crates/engine/src/cmd/web_cmds/optimizer.rs +++ b/crates/engine/src/cmd/web_cmds/optimizer.rs @@ -1,5 +1,5 @@ use super::settings::{WebFormat, WebMetadata, WebSettings}; -use super::*; +use super::{Dither, Document, EngineError, Forced, PaletteKind, Rect, Result, Session, Value, json, other, quantize}; // ---------- the image ---------- diff --git a/crates/engine/src/cmd/web_cmds/quick.rs b/crates/engine/src/cmd/web_cmds/quick.rs index c017eb3d2f..916a06f8d2 100644 --- a/crates/engine/src/cmd/web_cmds/quick.rs +++ b/crates/engine/src/cmd/web_cmds/quick.rs @@ -1,6 +1,6 @@ use super::optimizer::{optimize, web_document, web_exif, web_warnings, web_xmp}; use super::settings::WebSettings; -use super::*; +use super::{EngineError, Result, Session, Value, bad, join, json, other, stem, write_file}; // ---------- Export Preferences / Quick Export ---------- diff --git a/crates/engine/src/cmd/web_cmds/save_web.rs b/crates/engine/src/cmd/web_cmds/save_web.rs index eeca746754..1e263c2c25 100644 --- a/crates/engine/src/cmd/web_cmds/save_web.rs +++ b/crates/engine/src/cmd/web_cmds/save_web.rs @@ -1,7 +1,7 @@ use super::assets::{asset_output_path, asset_path_components}; use super::optimizer::{optimize, web_document, web_exif, web_warnings, web_xmp}; use super::settings::{WebSettings, rgb_hex}; -use super::*; +use super::{EngineError, Rect, ResolvedSlice, Result, Session, SliceKind, Value, bad, join, json, other, portable_device_name, slices, stem, write_file}; /// A file-name-safe slice name. fn slice_file(name: &str) -> String { diff --git a/crates/engine/src/cmd/web_cmds/settings.rs b/crates/engine/src/cmd/web_cmds/settings.rs index 19314673c0..a819cf6864 100644 --- a/crates/engine/src/cmd/web_cmds/settings.rs +++ b/crates/engine/src/cmd/web_cmds/settings.rs @@ -1,5 +1,5 @@ use super::optimizer::to8; -use super::*; +use super::{Dither, PaletteKind, Result, Value, bad, json}; // ---------- settings ---------- diff --git a/crates/engine/src/cmd/web_cmds/tests.rs b/crates/engine/src/cmd/web_cmds/tests.rs index 38fa82c188..cdc7d576e7 100644 --- a/crates/engine/src/cmd/web_cmds/tests.rs +++ b/crates/engine/src/cmd/web_cmds/tests.rs @@ -637,7 +637,7 @@ fn image_assets_needs_the_generator_preference() { fn image_assets_are_generated_now_and_on_save() { let dir = tmp("assets"); let mut s = session(8); - // Preferences ▸ Plug-ins ▸ Generator enabled: the feature is opt-in. + // Preferences ▸ Plug-ins ▸ Enable Generator: the feature is opt-in. s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); let id = s.active().unwrap().active_layer.unwrap(); s.execute("layer.renameLayer", json!({"layer": id.0, "name": "logo.png, 50% small/logo.gif, logo.jpg80%"})).unwrap(); @@ -661,7 +661,10 @@ fn image_assets_are_generated_now_and_on_save() { let s2 = session(8); let doc = s2.active().unwrap().doc.clone(); let mut d = (*doc).clone(); - d.layers.push(Layer::new("default 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }))); + d.layers.push(Layer::new( + "default 200% @2x", + LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }), + )); let lid = d.layers[1].id; d.layer_mut(lid).unwrap().name = "icon.png".into(); let (files, errors) = generate_assets(&d, &dir); @@ -684,7 +687,7 @@ fn image_assets_keep_full_artboards() { logo.surface_mut().unwrap().fill_rect(Rect::new(12, 10, 20, 16), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); let board = photocraft_doc::Artboard { rect: Rect::new(4, 6, 44, 36), background: photocraft_doc::ArtboardBackground::Transparent, preset: String::new() }; - let group = photocraft_doc::Group { children: vec![logo], expanded: true, artboard: Some(board) }; + let group = photocraft_doc::Group { children: vec![logo], expanded: true, artboard: Some(board), frame_stroke: None }; doc.layers = vec![Layer::new("board.png", LayerContent::Group(group))]; Ok(()) }) @@ -722,8 +725,8 @@ fn image_assets_refuse_empty_artboards() { background: photocraft_doc::ArtboardBackground::Transparent, preset: String::new(), }; - let empty = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board(0)) }; - let off = photocraft_doc::Group { children: vec![outside], expanded: true, artboard: Some(board(24)) }; + let empty = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board(0)), frame_stroke: None }; + let off = photocraft_doc::Group { children: vec![outside], expanded: true, artboard: Some(board(24)), frame_stroke: None }; doc.layers = vec![Layer::new("empty.png", LayerContent::Group(empty)), Layer::new("off.png", LayerContent::Group(off))]; Ok(()) }) @@ -770,8 +773,10 @@ fn image_assets_never_write_one_file_twice() { let dir = tmp("asset-variant-collisions"); d.layer_mut(top).unwrap().name = "icon.png".into(); d.layer_mut(logo).unwrap().name = "Logo".into(); - d.layers - .push(Layer::new("default 50% + 200% + 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }))); + d.layers.push(Layer::new( + "default 50% + 200% + 200% @2x", + LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }), + )); let (files, errors) = generate_assets(&d, &dir); let names: Vec<_> = files.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()).collect(); assert_eq!(names, ["icon.png", "icon@2x.png"]); @@ -799,7 +804,7 @@ fn image_assets_reject_traversal_from_names_and_defaults() { d.layer_mut(id).unwrap().name = "safe.png".into(); d.layers.push(Layer::new( "default ../ + /outside/ + ..\\bad/ + C:\\drive/ + 50% nested/", - LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }), + LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }), )); let (files, errors) = generate_assets(&d, &dir); assert_eq!( diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index ef7c9445e4..a3a4af7327 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Document, History, LayerId, LayerTarget, channel_cmds, float_cmds, history_cmds, layer_multi_cmds, symmetry_cmds}; /// Per-document editing state. #[derive(Clone, Debug)] diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index e033d8db93..635089f7a2 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,4 +1,4 @@ -use super::*; +use super::LayerId; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 7aa726f8e0..1afc11220e 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -66,343 +66,408 @@ fn list(p: &Value, k: &str) -> Option> { const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +/// The value `options` pairs with param `k` (default name `d`), or `other` for a name it doesn't list. +fn choice(p: &Value, k: &str, d: &str, options: &[(&str, T)], other: T) -> T { + let name = s(p, k, d); + options.iter().find(|(n, _)| *n == name).map_or(other, |&(_, v)| v) +} + +const MEZZOTINT_TYPES: &[(&str, MezzotintType)] = &[ + ("fineDots", MezzotintType::FineDots), + ("mediumDots", MezzotintType::MediumDots), + ("grainyDots", MezzotintType::GrainyDots), + ("coarseDots", MezzotintType::CoarseDots), + ("shortLines", MezzotintType::ShortLines), + ("mediumLines", MezzotintType::MediumLines), + ("longLines", MezzotintType::LongLines), + ("shortStrokes", MezzotintType::ShortStrokes), + ("mediumStrokes", MezzotintType::MediumStrokes), + ("longStrokes", MezzotintType::LongStrokes), +]; +const DIFFUSE_MODES: &[(&str, DiffuseMode)] = &[ + ("normal", DiffuseMode::Normal), + ("darkenOnly", DiffuseMode::DarkenOnly), + ("lightenOnly", DiffuseMode::LightenOnly), + ("anisotropic", DiffuseMode::Anisotropic), +]; +const TILE_FILLS: &[(&str, TileFill)] = + &[("background", TileFill::Background), ("foreground", TileFill::Foreground), ("inverse", TileFill::Inverse), ("unaltered", TileFill::Unaltered)]; +const WIND_METHODS: &[(&str, WindMethod)] = &[("wind", WindMethod::Wind), ("blast", WindMethod::Blast), ("stagger", WindMethod::Stagger)]; +const ZIGZAG_STYLES: &[(&str, ZigZagStyle)] = + &[("pondRipples", ZigZagStyle::PondRipples), ("outFromCenter", ZigZagStyle::OutFromCenter), ("aroundCenter", ZigZagStyle::AroundCenter)]; +const LENS_TYPES: &[(&str, LensType)] = + &[("zoom", LensType::Zoom), ("prime35", LensType::Prime35), ("prime105", LensType::Prime105), ("moviePrime", LensType::MoviePrime)]; +const LIGHT_KINDS: &[(&str, LightKind)] = &[("spot", LightKind::Spot), ("point", LightKind::Point), ("infinite", LightKind::Infinite)]; +const TEXTURE_CHANNELS: &[(&str, TextureChannel)] = &[ + ("none", TextureChannel::None), + ("red", TextureChannel::Red), + ("green", TextureChannel::Green), + ("blue", TextureChannel::Blue), + ("alpha", TextureChannel::Alpha), + ("luminance", TextureChannel::Luminance), +]; +const BLUR_QUALITIES: &[(&str, BlurQuality)] = &[("high", BlurQuality::High), ("low", BlurQuality::Low), ("medium", BlurQuality::Medium)]; +const SMART_BLUR_MODES: &[(&str, SmartBlurMode)] = + &[("normal", SmartBlurMode::Normal), ("edgeOnly", SmartBlurMode::EdgeOnly), ("overlayEdge", SmartBlurMode::OverlayEdge)]; +const IRIS_BLADES: &[(&str, u32)] = &[("triangle", 3), ("square", 4), ("pentagon", 5), ("hexagon", 6), ("heptagon", 7), ("octagon", 8)]; +const DEPTH_SOURCES: &[(&str, DepthSource)] = + &[("none", DepthSource::None), ("transparency", DepthSource::Transparency), ("layerMask", DepthSource::LayerMask), ("channel", DepthSource::Channel)]; +const BLUR_SHAPES: &[(&str, BlurShape)] = &[ + ("circle", BlurShape::Circle), + ("ring", BlurShape::Ring), + ("square", BlurShape::Square), + ("diamond", BlurShape::Diamond), + ("triangle", BlurShape::Triangle), + ("hexagon", BlurShape::Hexagon), + ("star", BlurShape::Star), + ("heart", BlurShape::Heart), + ("cross", BlurShape::Cross), +]; +const HSB_MODELS: &[(&str, HsbModel)] = &[("rgb", HsbModel::Rgb), ("hsb", HsbModel::Hsb), ("hsl", HsbModel::Hsl)]; + +type Build = fn(&Value) -> FilterParams; + +const PARAMS: &[(&str, Build)] = &[ + // Pixelate + ("filter.pixelate.colorHalftone", |p| FilterParams::ColorHalftone { + max_radius: f(p, "maxRadius", 8.0).clamp(4.0, 127.0), + angles: [f(p, "channel1", 108.0), f(p, "channel2", 162.0), f(p, "channel3", 90.0), f(p, "channel4", 45.0)], + }), + ("filter.pixelate.crystallize", |p| FilterParams::Crystallize { cell_size: f(p, "cellSize", 10.0).clamp(3.0, 300.0), seed: seed(p) }), + ("filter.pixelate.facet", |_| FilterParams::Facet), + ("filter.pixelate.fragment", |_| FilterParams::Fragment), + ("filter.pixelate.mezzotint", |p| FilterParams::Mezzotint { kind: choice(p, "type", "fineDots", MEZZOTINT_TYPES, MezzotintType::FineDots), seed: seed(p) }), + ("filter.pixelate.pointillize", |p| FilterParams::Pointillize { + cell_size: f(p, "cellSize", 5.0).clamp(3.0, 300.0), + seed: seed(p), + background: colour(p, "background", WHITE), + }), + // Stylize + ("filter.stylize.diffuse", |p| FilterParams::Diffuse { mode: choice(p, "mode", "normal", DIFFUSE_MODES, DiffuseMode::Normal), seed: seed(p) }), + ("filter.stylize.extrude", extrude), + ("filter.stylize.oilPaint", oil_paint), + ("filter.stylize.tiles", tiles), + ("filter.stylize.traceContour", |p| FilterParams::TraceContour { level: f(p, "level", 128.0).clamp(0.0, 255.0), upper: s(p, "edge", "lower") == "upper" }), + ("filter.stylize.wind", |p| FilterParams::Wind { + method: choice(p, "method", "wind", WIND_METHODS, WindMethod::Wind), + from_right: s(p, "direction", "fromRight") != "fromLeft", + seed: seed(p), + }), + // Distort + ("filter.distort.displace", displace), + ("filter.distort.shear", shear), + ("filter.distort.zigZag", |p| FilterParams::ZigZag { + amount: f(p, "amount", 10.0).clamp(-100.0, 100.0), + ridges: f(p, "ridges", 5.0).clamp(0.0, 20.0), + style: choice(p, "style", "pondRipples", ZIGZAG_STYLES, ZigZagStyle::PondRipples), + }), + // Render + ("filter.render.clouds", |p| FilterParams::Clouds { + seed: seed(p), + difference: false, + foreground: colour(p, "foreground", BLACK), + background: colour(p, "background", WHITE), + }), + ("filter.render.differenceClouds", |p| FilterParams::Clouds { + seed: seed(p), + difference: true, + foreground: colour(p, "foreground", BLACK), + background: colour(p, "background", WHITE), + }), + ("filter.render.fibers", |p| FilterParams::Fibers { + variance: f(p, "variance", 16.0).clamp(1.0, 64.0), + strength: f(p, "strength", 4.0).clamp(1.0, 64.0), + seed: seed(p), + foreground: colour(p, "foreground", BLACK), + background: colour(p, "background", WHITE), + }), + ("filter.render.lensFlare", lens_flare), + ("filter.render.relight", |p| FilterParams::Relight { + angle: f(p, "angle", 45.0).clamp(-180.0, 180.0), + elevation: f(p, "elevation", 40.0).clamp(0.0, 90.0), + intensity: f(p, "intensity", 40.0).clamp(0.0, 100.0), + ambient: f(p, "ambient", 55.0).clamp(0.0, 100.0), + warmth: f(p, "warmth", 0.0).clamp(-100.0, 100.0), + softness: f(p, "softness", 25.0).clamp(1.0, 100.0), + }), + ("filter.render.lightingEffects", lighting_effects), + // Noise + ("filter.noise.reduceNoise", |p| FilterParams::ReduceNoise { + strength: f(p, "strength", 6.0).clamp(0.0, 10.0), + preserve_details: f(p, "preserveDetails", 60.0).clamp(0.0, 100.0), + reduce_color_noise: f(p, "reduceColorNoise", 45.0).clamp(0.0, 100.0), + sharpen_details: f(p, "sharpenDetails", 25.0).clamp(0.0, 100.0), + remove_jpeg_artifact: b(p, "removeJpegArtifact", false), + }), + // Blur + ("filter.blur.smartBlur", smart_blur), + ("filter.blur.lensBlur", lens_blur), + ("filter.blur.shapeBlur", |p| FilterParams::ShapeBlur { + radius: f(p, "radius", 10.0).clamp(5.0, 1000.0), + shape: choice(p, "shape", "circle", BLUR_SHAPES, BlurShape::Circle), + }), + // Blur Gallery + ("filter.blurGallery.tiltShift", tilt_shift), + ("filter.blurGallery.irisBlur", iris_blur), + ("filter.blurGallery.fieldBlur", |p| FilterParams::FieldBlur { + pins: list::(p, "pins").unwrap_or_else(|| vec![FieldPin { x: f(p, "centerX", 0.5), y: f(p, "centerY", 0.5), blur: gallery_blur(p) }]), + }), + ("filter.blurGallery.spinBlur", spin_blur), + ("filter.blurGallery.pathBlur", path_blur), + // Other + ("filter.other.custom", custom), + ("filter.other.hsbHsl", |p| FilterParams::HsbHsl { + input: choice(p, "inputMode", "rgb", HSB_MODELS, HsbModel::Rgb), + output: choice(p, "rowOrder", "hsb", HSB_MODELS, HsbModel::Rgb), + }), + (crate::color_to_alpha_cmds::ID, crate::color_to_alpha_cmds::params), + // Video + ("filter.video.deInterlace", |p| FilterParams::DeInterlace { + eliminate_even: s(p, "eliminate", "oddFields") == "evenFields", + interpolate: s(p, "createBy", "interpolation") != "duplication", + }), + ("filter.video.ntscColors", |_| FilterParams::NtscColors), +]; + /// Algorithm parameters for the ids this module owns (Photoshop dialog units). pub fn params_for(id: &str, p: &Value) -> Option { - let blur = |d: f32| f(p, "blur", d).clamp(0.0, 500.0); - Some(match id { - // ---- Pixelate ---- - "filter.pixelate.colorHalftone" => FilterParams::ColorHalftone { - max_radius: f(p, "maxRadius", 8.0).clamp(4.0, 127.0), - angles: [f(p, "channel1", 108.0), f(p, "channel2", 162.0), f(p, "channel3", 90.0), f(p, "channel4", 45.0)], - }, - "filter.pixelate.crystallize" => FilterParams::Crystallize { cell_size: f(p, "cellSize", 10.0).clamp(3.0, 300.0), seed: seed(p) }, - "filter.pixelate.facet" => FilterParams::Facet, - "filter.pixelate.fragment" => FilterParams::Fragment, - "filter.pixelate.mezzotint" => FilterParams::Mezzotint { - kind: match s(p, "type", "fineDots") { - "mediumDots" => MezzotintType::MediumDots, - "grainyDots" => MezzotintType::GrainyDots, - "coarseDots" => MezzotintType::CoarseDots, - "shortLines" => MezzotintType::ShortLines, - "mediumLines" => MezzotintType::MediumLines, - "longLines" => MezzotintType::LongLines, - "shortStrokes" => MezzotintType::ShortStrokes, - "mediumStrokes" => MezzotintType::MediumStrokes, - "longStrokes" => MezzotintType::LongStrokes, - _ => MezzotintType::FineDots, - }, - seed: seed(p), - }, - "filter.pixelate.pointillize" => { - FilterParams::Pointillize { cell_size: f(p, "cellSize", 5.0).clamp(3.0, 300.0), seed: seed(p), background: colour(p, "background", WHITE) } - } - // ---- Stylize ---- - "filter.stylize.diffuse" => FilterParams::Diffuse { - mode: match s(p, "mode", "normal") { - "darkenOnly" => DiffuseMode::DarkenOnly, - "lightenOnly" => DiffuseMode::LightenOnly, - "anisotropic" => DiffuseMode::Anisotropic, - _ => DiffuseMode::Normal, - }, - seed: seed(p), - }, - "filter.stylize.extrude" => FilterParams::Extrude { - kind: if s(p, "type", "blocks") == "pyramids" { ExtrudeType::Pyramids } else { ExtrudeType::Blocks }, - size: f(p, "size", 30.0).clamp(2.0, 255.0), - depth: f(p, "depth", 30.0).clamp(1.0, 255.0), - level_based: s(p, "depthMode", "random") == "levelBased", - solid_front: b(p, "solidFrontFaces", false), - mask_incomplete: b(p, "maskIncompleteBlocks", false), - seed: seed(p), - }, - "filter.stylize.oilPaint" => FilterParams::OilPaint { - stylization: f(p, "stylization", 4.0).clamp(0.1, 10.0), - cleanliness: f(p, "cleanliness", 5.0).clamp(0.0, 10.0), - scale: f(p, "scale", 1.0).clamp(0.1, 10.0), - bristle_detail: f(p, "bristleDetail", 5.0).clamp(0.0, 10.0), - lighting: b(p, "lighting", true), - angle: f(p, "angle", -60.0), - shine: f(p, "shine", 1.0).clamp(0.0, 10.0), - }, - "filter.stylize.tiles" => FilterParams::Tiles { - count: i(p, "count", 10).clamp(1, 99) as u32, - max_offset: f(p, "maxOffset", 10.0).clamp(1.0, 99.0), - fill: match s(p, "fill", "background") { - "foreground" => TileFill::Foreground, - "inverse" => TileFill::Inverse, - "unaltered" => TileFill::Unaltered, - _ => TileFill::Background, - }, - foreground: colour(p, "foreground", BLACK), - background: colour(p, "background", WHITE), - seed: seed(p), - }, - "filter.stylize.traceContour" => FilterParams::TraceContour { level: f(p, "level", 128.0).clamp(0.0, 255.0), upper: s(p, "edge", "lower") == "upper" }, - "filter.stylize.wind" => FilterParams::Wind { - method: match s(p, "method", "wind") { - "blast" => WindMethod::Blast, - "stagger" => WindMethod::Stagger, - _ => WindMethod::Wind, - }, - from_right: s(p, "direction", "fromRight") != "fromLeft", - seed: seed(p), - }, - // ---- Distort ---- - "filter.distort.displace" => FilterParams::Displace { - horizontal: f(p, "horizontal", 10.0).clamp(-999.0, 999.0), - vertical: f(p, "vertical", 10.0).clamp(-999.0, 999.0), - stretch: s(p, "fit", "stretch") != "tile", - undefined: undefined(p, "repeat"), - // A Displace imported from PSD carries its map; `mapPath` / `mapDocument` resolve later. - map: photocraft_io::smart_map::displace_embedded_map(p), - }, - "filter.distort.shear" => FilterParams::Shear { - points: list::<[f32; 2]>(p, "points").unwrap_or_else(|| { - let a = f(p, "amount", 0.0).clamp(-100.0, 100.0) / 100.0; - vec![[0.0, 0.0], [0.5, a], [1.0, 0.0]] - }), - undefined: undefined(p, "wrap"), - }, - "filter.distort.zigZag" => FilterParams::ZigZag { - amount: f(p, "amount", 10.0).clamp(-100.0, 100.0), - ridges: f(p, "ridges", 5.0).clamp(0.0, 20.0), - style: match s(p, "style", "pondRipples") { - "outFromCenter" => ZigZagStyle::OutFromCenter, - "aroundCenter" => ZigZagStyle::AroundCenter, - _ => ZigZagStyle::PondRipples, - }, - }, - // ---- Render ---- - "filter.render.clouds" | "filter.render.differenceClouds" => FilterParams::Clouds { - seed: seed(p), - difference: id == "filter.render.differenceClouds", - foreground: colour(p, "foreground", BLACK), - background: colour(p, "background", WHITE), - }, - "filter.render.fibers" => FilterParams::Fibers { - variance: f(p, "variance", 16.0).clamp(1.0, 64.0), - strength: f(p, "strength", 4.0).clamp(1.0, 64.0), - seed: seed(p), - foreground: colour(p, "foreground", BLACK), - background: colour(p, "background", WHITE), - }, - "filter.render.lensFlare" => FilterParams::LensFlare { - brightness: f(p, "brightness", 100.0).clamp(10.0, 300.0), - center_x: f(p, "centerX", 0.5).clamp(-0.5, 1.5), - center_y: f(p, "centerY", 0.5).clamp(-0.5, 1.5), - lens: match s(p, "lens", "zoom") { - "prime35" => LensType::Prime35, - "prime105" => LensType::Prime105, - "moviePrime" => LensType::MoviePrime, - _ => LensType::Zoom, - }, - }, - "filter.render.relight" => FilterParams::Relight { - angle: f(p, "angle", 45.0).clamp(-180.0, 180.0), - elevation: f(p, "elevation", 40.0).clamp(0.0, 90.0), - intensity: f(p, "intensity", 40.0).clamp(0.0, 100.0), - ambient: f(p, "ambient", 55.0).clamp(0.0, 100.0), - warmth: f(p, "warmth", 0.0).clamp(-100.0, 100.0), - softness: f(p, "softness", 25.0).clamp(1.0, 100.0), - }, - "filter.render.lightingEffects" => FilterParams::LightingEffects { - lights: list::(p, "lights").unwrap_or_else(|| { - vec![Light { - kind: match s(p, "lightType", "spot") { - "point" => LightKind::Point, - "infinite" => LightKind::Infinite, - _ => LightKind::Spot, - }, - x: f(p, "lightX", 0.25), - y: f(p, "lightY", 0.2), - z: f(p, "lightZ", 0.6).clamp(0.0, 10.0), - target_x: f(p, "targetX", 0.5), - target_y: f(p, "targetY", 0.55), - angle: f(p, "angle", 135.0), - elevation: f(p, "elevation", 45.0).clamp(0.0, 90.0), - color: { - let c = colour(p, "color", WHITE); - [c[0], c[1], c[2]] - }, - intensity: f(p, "intensity", 75.0).clamp(-100.0, 100.0), - cone: f(p, "cone", 45.0).clamp(1.0, 89.0), - hotspot: f(p, "hotspot", 50.0).clamp(0.0, 100.0), - radius: f(p, "radius", 0.8).clamp(0.01, 10.0), - }] - }), - gloss: f(p, "gloss", 0.0).clamp(-100.0, 100.0), - metallic: f(p, "metallic", 0.0).clamp(-100.0, 100.0), - exposure: f(p, "exposure", 0.0).clamp(-100.0, 100.0), - ambience: f(p, "ambience", 8.0).clamp(-100.0, 100.0), - texture: match s(p, "texture", "none") { - "red" => TextureChannel::Red, - "green" => TextureChannel::Green, - "blue" => TextureChannel::Blue, - "alpha" => TextureChannel::Alpha, - "luminance" => TextureChannel::Luminance, - _ => TextureChannel::None, - }, - height: f(p, "height", 50.0).clamp(0.0, 100.0), - white_is_high: b(p, "whiteIsHigh", true), - }, - // ---- Noise ---- - "filter.noise.reduceNoise" => FilterParams::ReduceNoise { - strength: f(p, "strength", 6.0).clamp(0.0, 10.0), - preserve_details: f(p, "preserveDetails", 60.0).clamp(0.0, 100.0), - reduce_color_noise: f(p, "reduceColorNoise", 45.0).clamp(0.0, 100.0), - sharpen_details: f(p, "sharpenDetails", 25.0).clamp(0.0, 100.0), - remove_jpeg_artifact: b(p, "removeJpegArtifact", false), - }, - // ---- Blur ---- - "filter.blur.smartBlur" => FilterParams::SmartBlur { - radius: f(p, "radius", 3.0).clamp(0.1, 100.0), - threshold: f(p, "threshold", 25.0).clamp(0.1, 100.0), - quality: match s(p, "quality", "high") { - "low" => BlurQuality::Low, - "medium" => BlurQuality::Medium, - _ => BlurQuality::High, - }, - mode: match s(p, "mode", "normal") { - "edgeOnly" => SmartBlurMode::EdgeOnly, - "overlayEdge" => SmartBlurMode::OverlayEdge, - _ => SmartBlurMode::Normal, - }, - }, - "filter.blur.lensBlur" => FilterParams::LensBlur { - radius: f(p, "radius", 15.0).clamp(0.0, 100.0), - blades: match s(p, "shape", "hexagon") { - "triangle" => 3, - "square" => 4, - "pentagon" => 5, - "heptagon" => 7, - "octagon" => 8, - _ => 6, - }, - curvature: f(p, "bladeCurvature", 0.0).clamp(0.0, 100.0), - rotation: f(p, "rotation", 0.0), - depth: match s(p, "depthMap", "none") { - "transparency" => DepthSource::Transparency, - "layerMask" => DepthSource::LayerMask, - "channel" => DepthSource::Channel, - _ => DepthSource::None, - }, - focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), - invert_depth: b(p, "invert", false), - brightness: f(p, "brightness", 0.0).clamp(0.0, 100.0), - threshold: f(p, "threshold", 255.0).clamp(0.0, 255.0), - noise: f(p, "noise", 0.0).clamp(0.0, 100.0), - distribution: if s(p, "distribution", "uniform") == "gaussian" { Distribution::Gaussian } else { Distribution::Uniform }, - monochromatic: b(p, "monochromatic", false), - seed: seed(p), - depth_map: None, - }, - "filter.blur.shapeBlur" => FilterParams::ShapeBlur { - radius: f(p, "radius", 10.0).clamp(5.0, 1000.0), - shape: match s(p, "shape", "circle") { - "ring" => BlurShape::Ring, - "square" => BlurShape::Square, - "diamond" => BlurShape::Diamond, - "triangle" => BlurShape::Triangle, - "hexagon" => BlurShape::Hexagon, - "star" => BlurShape::Star, - "heart" => BlurShape::Heart, - "cross" => BlurShape::Cross, - _ => BlurShape::Circle, - }, - }, - // ---- Blur Gallery ---- - "filter.blurGallery.tiltShift" => FilterParams::TiltShift { - blur: blur(15.0), - center_x: f(p, "centerX", 0.5), - center_y: f(p, "centerY", 0.5), - angle: f(p, "angle", 0.0), - focus: f(p, "focus", 0.1).clamp(0.0, 2.0), - transition: f(p, "transition", 0.15).clamp(0.001, 2.0), - }, - "filter.blurGallery.irisBlur" => FilterParams::IrisBlur { - pins: list::(p, "pins").unwrap_or_else(|| { - vec![IrisPin { + match PARAMS.iter().find(|(k, _)| *k == id) { + Some((_, build)) => Some(build(p)), + None => crate::gallery_cmds::params_for(id, p), + } +} + +/// The blur amount the Blur Gallery filters share. +fn gallery_blur(p: &Value) -> f32 { + f(p, "blur", 15.0).clamp(0.0, 500.0) +} + +/// Filter › Stylize › Extrude parameters. +fn extrude(p: &Value) -> FilterParams { + FilterParams::Extrude { + kind: if s(p, "type", "blocks") == "pyramids" { ExtrudeType::Pyramids } else { ExtrudeType::Blocks }, + size: f(p, "size", 30.0).clamp(2.0, 255.0), + depth: f(p, "depth", 30.0).clamp(1.0, 255.0), + level_based: s(p, "depthMode", "random") == "levelBased", + solid_front: b(p, "solidFrontFaces", false), + mask_incomplete: b(p, "maskIncompleteBlocks", false), + seed: seed(p), + } +} + +/// Filter › Stylize › Oil Paint parameters. +fn oil_paint(p: &Value) -> FilterParams { + FilterParams::OilPaint { + stylization: f(p, "stylization", 4.0).clamp(0.1, 10.0), + cleanliness: f(p, "cleanliness", 5.0).clamp(0.0, 10.0), + scale: f(p, "scale", 1.0).clamp(0.1, 10.0), + bristle_detail: f(p, "bristleDetail", 5.0).clamp(0.0, 10.0), + lighting: b(p, "lighting", true), + angle: f(p, "angle", -60.0), + shine: f(p, "shine", 1.0).clamp(0.0, 10.0), + } +} + +/// Filter › Stylize › Tiles parameters. +fn tiles(p: &Value) -> FilterParams { + FilterParams::Tiles { + count: i(p, "count", 10).clamp(1, 99) as u32, + max_offset: f(p, "maxOffset", 10.0).clamp(1.0, 99.0), + fill: choice(p, "fill", "background", TILE_FILLS, TileFill::Background), + foreground: colour(p, "foreground", BLACK), + background: colour(p, "background", WHITE), + seed: seed(p), + } +} + +/// Filter › Distort › Displace parameters. +fn displace(p: &Value) -> FilterParams { + FilterParams::Displace { + horizontal: f(p, "horizontal", 10.0).clamp(-999.0, 999.0), + vertical: f(p, "vertical", 10.0).clamp(-999.0, 999.0), + stretch: s(p, "fit", "stretch") != "tile", + undefined: undefined(p, "repeat"), + // PSD imports carry the map, and `mapPath` or `mapDocument` resolve later. + map: photocraft_io::smart_map::displace_embedded_map(p), + } +} + +/// Filter › Distort › Shear parameters: the listed curve points, or one bend from `amount`. +fn shear(p: &Value) -> FilterParams { + FilterParams::Shear { + points: list::<[f32; 2]>(p, "points").unwrap_or_else(|| { + let a = f(p, "amount", 0.0).clamp(-100.0, 100.0) / 100.0; + vec![[0.0, 0.0], [0.5, a], [1.0, 0.0]] + }), + undefined: undefined(p, "wrap"), + } +} + +/// Filter › Render › Lens Flare parameters. +fn lens_flare(p: &Value) -> FilterParams { + FilterParams::LensFlare { + brightness: f(p, "brightness", 100.0).clamp(10.0, 300.0), + center_x: f(p, "centerX", 0.5).clamp(-0.5, 1.5), + center_y: f(p, "centerY", 0.5).clamp(-0.5, 1.5), + lens: choice(p, "lens", "zoom", LENS_TYPES, LensType::Zoom), + } +} + +/// The one light Lighting Effects uses when the params list no `lights`. +fn single_light(p: &Value) -> Light { + let c = colour(p, "color", WHITE); + Light { + kind: choice(p, "lightType", "spot", LIGHT_KINDS, LightKind::Spot), + x: f(p, "lightX", 0.25), + y: f(p, "lightY", 0.2), + z: f(p, "lightZ", 0.6).clamp(0.0, 10.0), + target_x: f(p, "targetX", 0.5), + target_y: f(p, "targetY", 0.55), + angle: f(p, "angle", 135.0), + elevation: f(p, "elevation", 45.0).clamp(0.0, 90.0), + color: [c[0], c[1], c[2]], + intensity: f(p, "intensity", 75.0).clamp(-100.0, 100.0), + cone: f(p, "cone", 45.0).clamp(1.0, 89.0), + hotspot: f(p, "hotspot", 50.0).clamp(0.0, 100.0), + radius: f(p, "radius", 0.8).clamp(0.01, 10.0), + } +} + +/// Filter › Render › Lighting Effects parameters. +fn lighting_effects(p: &Value) -> FilterParams { + FilterParams::LightingEffects { + lights: list::(p, "lights").unwrap_or_else(|| vec![single_light(p)]), + gloss: f(p, "gloss", 0.0).clamp(-100.0, 100.0), + metallic: f(p, "metallic", 0.0).clamp(-100.0, 100.0), + exposure: f(p, "exposure", 0.0).clamp(-100.0, 100.0), + ambience: f(p, "ambience", 8.0).clamp(-100.0, 100.0), + texture: choice(p, "texture", "none", TEXTURE_CHANNELS, TextureChannel::None), + height: f(p, "height", 50.0).clamp(0.0, 100.0), + white_is_high: b(p, "whiteIsHigh", true), + } +} + +/// Filter › Blur › Smart Blur parameters. +fn smart_blur(p: &Value) -> FilterParams { + FilterParams::SmartBlur { + radius: f(p, "radius", 3.0).clamp(0.1, 100.0), + threshold: f(p, "threshold", 25.0).clamp(0.1, 100.0), + quality: choice(p, "quality", "high", BLUR_QUALITIES, BlurQuality::High), + mode: choice(p, "mode", "normal", SMART_BLUR_MODES, SmartBlurMode::Normal), + } +} + +/// Filter › Blur › Lens Blur parameters, with the depth map resolved later. +fn lens_blur(p: &Value) -> FilterParams { + FilterParams::LensBlur { + radius: f(p, "radius", 15.0).clamp(0.0, 100.0), + blades: choice(p, "shape", "hexagon", IRIS_BLADES, 6), + curvature: f(p, "bladeCurvature", 0.0).clamp(0.0, 100.0), + rotation: f(p, "rotation", 0.0), + depth: choice(p, "depthMap", "none", DEPTH_SOURCES, DepthSource::None), + focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), + invert_depth: b(p, "invert", false), + brightness: f(p, "brightness", 0.0).clamp(0.0, 100.0), + threshold: f(p, "threshold", 255.0).clamp(0.0, 255.0), + noise: f(p, "noise", 0.0).clamp(0.0, 100.0), + distribution: if s(p, "distribution", "uniform") == "gaussian" { Distribution::Gaussian } else { Distribution::Uniform }, + monochromatic: b(p, "monochromatic", false), + seed: seed(p), + depth_map: None, + } +} + +/// Blur Gallery › Tilt-Shift parameters. +fn tilt_shift(p: &Value) -> FilterParams { + FilterParams::TiltShift { + blur: gallery_blur(p), + center_x: f(p, "centerX", 0.5), + center_y: f(p, "centerY", 0.5), + angle: f(p, "angle", 0.0), + focus: f(p, "focus", 0.1).clamp(0.0, 2.0), + transition: f(p, "transition", 0.15).clamp(0.001, 2.0), + } +} + +/// Blur Gallery › Iris Blur parameters: the listed pins, or one pin from the flat params. +fn iris_blur(p: &Value) -> FilterParams { + FilterParams::IrisBlur { + pins: list::(p, "pins").unwrap_or_else(|| { + vec![IrisPin { + x: f(p, "centerX", 0.5), + y: f(p, "centerY", 0.5), + radius_x: f(p, "radiusX", 0.35).max(0.001), + radius_y: f(p, "radiusY", 0.25).max(0.001), + angle: f(p, "angle", 0.0), + roundness: f(p, "roundness", 0.0).clamp(0.0, 100.0), + feather: f(p, "feather", 0.5).clamp(0.0, 0.99), + blur: gallery_blur(p), + }] + }), + } +} + +/// `a` clamped to `lo..=360`, or 0 when it isn't finite. +fn spin_angle(a: f32, lo: f32) -> f32 { + if a.is_finite() { a.clamp(lo, 360.0) } else { 0.0 } +} + +/// Blur Gallery › Spin Blur parameters: the listed pins with bounded angles, or one pin from the flat params. +fn spin_blur(p: &Value) -> FilterParams { + FilterParams::SpinBlur { + pins: list::(p, "pins") + .map(|mut v| { + for pin in &mut v { + pin.angle = spin_angle(pin.angle, -360.0); + pin.blur_angle = spin_angle(pin.blur_angle, 0.0); + } + v + }) + .unwrap_or_else(|| { + vec![SpinPin { x: f(p, "centerX", 0.5), y: f(p, "centerY", 0.5), - radius_x: f(p, "radiusX", 0.35).max(0.001), - radius_y: f(p, "radiusY", 0.25).max(0.001), - angle: f(p, "angle", 0.0), - roundness: f(p, "roundness", 0.0).clamp(0.0, 100.0), - feather: f(p, "feather", 0.5).clamp(0.0, 0.99), - blur: blur(15.0), + radius_x: f(p, "radiusX", 0.3).max(0.001), + radius_y: f(p, "radiusY", 0.3).max(0.001), + angle: spin_angle(f(p, "angle", 0.0), -360.0), + blur_angle: f(p, "blurAngle", 15.0).clamp(0.0, 360.0), }] }), - }, - "filter.blurGallery.fieldBlur" => FilterParams::FieldBlur { - pins: list::(p, "pins").unwrap_or_else(|| vec![FieldPin { x: f(p, "centerX", 0.5), y: f(p, "centerY", 0.5), blur: blur(15.0) }]), - }, - "filter.blurGallery.spinBlur" => FilterParams::SpinBlur { - pins: list::(p, "pins") - .map(|mut v| { - for pin in &mut v { - pin.angle = if pin.angle.is_finite() { pin.angle.clamp(-360.0, 360.0) } else { 0.0 }; - pin.blur_angle = if pin.blur_angle.is_finite() { pin.blur_angle.clamp(0.0, 360.0) } else { 0.0 }; - } - v - }) - .unwrap_or_else(|| { - let a = f(p, "angle", 0.0); - vec![SpinPin { - x: f(p, "centerX", 0.5), - y: f(p, "centerY", 0.5), - radius_x: f(p, "radiusX", 0.3).max(0.001), - radius_y: f(p, "radiusY", 0.3).max(0.001), - angle: if a.is_finite() { a.clamp(-360.0, 360.0) } else { 0.0 }, - blur_angle: f(p, "blurAngle", 15.0).clamp(0.0, 360.0), - }] - }), - }, - "filter.blurGallery.pathBlur" => FilterParams::PathBlur { - paths: list::(p, "paths") - .map(|mut v| { - // Entry speeds bypass the scalar clamp above; the halo - // casts one to i32 and adds, so an unclamped value - // overflows (#708). - for b in &mut v { - b.speed = b.speed.clamp(0.0, 500.0); - } - v - }) - .unwrap_or_else(|| { - vec![BlurPath { - points: vec![[f(p, "startX", 0.2), f(p, "startY", 0.5)], [f(p, "endX", 0.8), f(p, "endY", 0.5)]], - speed: f(p, "speed", 50.0).clamp(0.0, 500.0), - taper: f(p, "taper", 0.0).clamp(0.0, 100.0), - }] - }), - }, - // ---- Other ---- - "filter.other.custom" => FilterParams::Custom { - kernel: p - .get("kernel") - .and_then(Value::as_array) - .map(|a| a.iter().take(25).map(|v| v.as_f64().unwrap_or(0.0).clamp(-999.0, 999.0) as f32).collect()) - .unwrap_or_else(|| (0..25).map(|k| if k == 12 { 1.0 } else { 0.0 }).collect()), - scale: f(p, "scale", 1.0).clamp(1.0, 9999.0), - offset: f(p, "offset", 0.0).clamp(-9999.0, 9999.0), - }, - "filter.other.hsbHsl" => { - let m = |k: &str, d: &str| match s(p, k, d) { - "hsb" => HsbModel::Hsb, - "hsl" => HsbModel::Hsl, - _ => HsbModel::Rgb, - }; - FilterParams::HsbHsl { input: m("inputMode", "rgb"), output: m("rowOrder", "hsb") } - } - crate::color_to_alpha_cmds::ID => crate::color_to_alpha_cmds::params(p), - // ---- Video ---- - "filter.video.deInterlace" => FilterParams::DeInterlace { - eliminate_even: s(p, "eliminate", "oddFields") == "evenFields", - interpolate: s(p, "createBy", "interpolation") != "duplication", - }, - "filter.video.ntscColors" => FilterParams::NtscColors, - _ => return crate::gallery_cmds::params_for(id, p), - }) + } +} + +/// Blur Gallery › Path Blur parameters: the listed paths with bounded speeds, or one straight path from the flat params. +fn path_blur(p: &Value) -> FilterParams { + FilterParams::PathBlur { + paths: list::(p, "paths") + .map(|mut v| { + // The halo casts speeds to i32, so unclamped entries overflow (#708). + for b in &mut v { + b.speed = b.speed.clamp(0.0, 500.0); + } + v + }) + .unwrap_or_else(|| { + vec![BlurPath { + points: vec![[f(p, "startX", 0.2), f(p, "startY", 0.5)], [f(p, "endX", 0.8), f(p, "endY", 0.5)]], + speed: f(p, "speed", 50.0).clamp(0.0, 500.0), + taper: f(p, "taper", 0.0).clamp(0.0, 100.0), + }] + }), + } +} + +/// Filter › Other › Custom parameters: up to 25 kernel weights (the identity by default), scale and offset. +fn custom(p: &Value) -> FilterParams { + FilterParams::Custom { + kernel: p + .get("kernel") + .and_then(Value::as_array) + .map(|a| a.iter().take(25).map(|v| v.as_f64().unwrap_or(0.0).clamp(-999.0, 999.0) as f32).collect()) + .unwrap_or_else(|| (0..25).map(|k| if k == 12 { 1.0 } else { 0.0 }).collect()), + scale: f(p, "scale", 1.0).clamp(1.0, 9999.0), + offset: f(p, "offset", 0.0).clamp(-9999.0, 9999.0), + } } /// Ids whose rendering uses the foreground/background colours. diff --git a/crates/engine/src/group_options.rs b/crates/engine/src/group_options.rs new file mode 100644 index 0000000000..dd2ddee02f --- /dev/null +++ b/crates/engine/src/group_options.rs @@ -0,0 +1,264 @@ +//! The options of Photoshop's New Group dialog (Name, Color, Mode, Opacity), shared by +//! Layer › New › Group… (`layer.new.group`) and Group from Layers (`layer.new.groupFromLayers`, +//! `layer.groupLayers`), so the dialog's OK is a single history step. + +use photocraft_color::BlendMode; +use photocraft_doc::{LabelColor, Layer}; +use serde_json::Value; + +use crate::{EngineError, Result}; + +/// New Group options parsed from command params; an absent (or `null`) key keeps the default. +#[derive(Debug, Default)] +pub(crate) struct GroupOptions { + /// `None` (or an empty name) takes the next "Group N". + pub name: Option, + color: Option, + blend: Option, + opacity: Option, +} + +impl GroupOptions { + /// Parses `name`, `color` (a layer colour id), `mode` (a blend mode, Pass Through included) + /// and `opacity` (percent, clamped to 0..100). Validates everything before any edit runs. + pub(crate) fn parse(cmd: &str, p: &Value) -> Result { + let bad = |msg: &str| EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }; + let get = |key: &str| p.get(key).filter(|v| !v.is_null()); + let name = match get("name") { + None => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()).filter(|n| !n.trim().is_empty()), + }; + let color = match get("color") { + None => None, + Some(v) => Some(v.as_str().and_then(LabelColor::from_id).ok_or_else(|| { + bad("`color` must be a layer colour id (none, red, orange, yellow, green, seafoam, blue, indigo, magenta, fuchsia, violet, gray)") + })?), + }; + let blend = match get("mode") { + None => None, + Some(v) => Some( + v.as_str() + .and_then(crate::commands::blend_from_str) + .ok_or_else(|| bad("`mode` must be a blend mode such as \"passThrough\" or \"multiply\""))?, + ), + }; + let opacity = match get("opacity") { + None => None, + Some(v) => { + let o = v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad("`opacity` must be a number (percent, 0..100)"))?; + Some((o.clamp(0.0, 100.0) / 100.0) as f32) + } + }; + Ok(Self { name, color, blend, opacity }) + } + + /// Sets the mode, opacity and colour of a new `group`. A colour also reaches every layer inside + /// it, as Layer › Layer Color does on a group; "No Color" leaves the layers' own colours alone. + pub(crate) fn apply(&self, group: &mut Layer) { + if let Some(blend) = self.blend { + group.blend = blend; + } + if let Some(opacity) = self.opacity { + group.opacity = opacity; + } + match self.color { + Some(LabelColor::None) | None => {} + Some(color) => { + // Explicit stack: documents can nest groups deeply. + let mut stack = vec![group]; + while let Some(l) = stack.pop() { + l.label = color; + if let Some(children) = l.children_mut() { + stack.extend(children.iter_mut()); + } + } + } + } + } +} + +/// `layer.new.group`'s `position`: `below` (the Layers panel's ⌘/Ctrl-click) or `above` (the default). +pub(crate) fn below(cmd: &str, p: &Value) -> Result { + match p.get("position").filter(|v| !v.is_null()) { + None => Ok(false), + Some(v) => match v.as_str() { + Some("above") => Ok(false), + Some("below") => Ok(true), + _ => Err(EngineError::BadParams { cmd: cmd.into(), msg: "`position` must be above or below".into() }), + }, + } +} + +#[cfg(test)] +mod tests { + use photocraft_color::BlendMode; + use photocraft_doc::{Document, LabelColor, Layer, LayerContent, LayerId}; + use serde_json::{Value, json}; + + use crate::Session; + + /// Background, then `A` and `B` (`B` active). + fn session() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let a = id(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()); + let b = id(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()); + (s, a, b) + } + + fn id(v: Value) -> LayerId { + LayerId(v["layer"].as_u64().unwrap()) + } + + fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc + } + + fn layer(s: &Session, id: LayerId) -> &Layer { + doc(s).layer(id).unwrap() + } + + fn expanded(s: &Session, id: LayerId) -> bool { + matches!(&layer(s, id).content, LayerContent::Group(g) if g.expanded) + } + + fn steps(s: &Session) -> usize { + s.active().unwrap().history.entries().len() + } + + fn top(s: &Session) -> Vec { + doc(s).layers.iter().map(|l| l.id).collect() + } + + #[test] + fn new_group_defaults_and_dialog_options() { + let (mut s, _, _) = session(); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + let l = layer(&s, g); + assert_eq!((l.name.as_str(), l.blend, l.opacity, l.label), ("Group 1", BlendMode::PassThrough, 1.0, LabelColor::None)); + assert!(expanded(&s, g), "an empty new group stays open"); + let before = steps(&s); + let g = id(s.execute("layer.new.group", json!({"name": "Sky", "color": "red", "mode": "multiply", "opacity": 40})).unwrap()); + let l = layer(&s, g); + assert_eq!((l.name.as_str(), l.blend, l.label), ("Sky", BlendMode::Multiply, LabelColor::Red)); + assert!((l.opacity - 0.4).abs() < 1e-6); + assert_eq!(steps(&s), before + 1, "the dialog's OK is one history step"); + // Labels as shown in the dialog work too; opacity is clamped to 0..100 %, a blank name is "Group N". + let g = id(s.execute("layer.new.group", json!({"name": " ", "mode": "Linear Dodge (Add)", "opacity": 250})).unwrap()); + let l = layer(&s, g); + assert_eq!((l.name.as_str(), l.blend, l.opacity), ("Group 2", BlendMode::LinearDodge, 1.0)); + let g = id(s.execute("layer.new.group", json!({"opacity": -5, "name": null, "color": null, "mode": null, "position": null})).unwrap()); + assert_eq!(layer(&s, g).opacity, 0.0); + } + + #[test] + fn group_from_layers_is_collapsed_and_takes_the_dialog_options() { + let (mut s, a, b) = session(); + s.execute("layer.setLabelColor", json!({"layer": a.0, "color": "red"})).unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let before = steps(&s); + let g = id(s.execute("layer.new.groupFromLayers", json!({"name": "Pair", "color": "none", "mode": "normal", "opacity": 50})).unwrap()); + assert_eq!(steps(&s), before + 1, "one history step"); + let l = layer(&s, g); + assert_eq!((l.name.as_str(), l.blend, l.opacity, l.label), ("Pair", BlendMode::Normal, 0.5, LabelColor::None)); + assert!(!expanded(&s, g), "Photoshop shows a group made from layers closed"); + assert_eq!(layer(&s, a).label, LabelColor::Red, "No Color leaves the layers' own colours alone"); + let inspect = s.execute("document.inspect", json!({})).unwrap(); + let row = inspect["layers"].as_array().unwrap().iter().find(|l| l["id"] == json!(g.0)).unwrap().clone(); + assert_eq!(row["expanded"], false, "document.inspect reports it"); + // Undo removes the group; redo brings it back closed. + s.undo(); + assert!(doc(&s).layer(g).is_none()); + s.redo(); + assert!(!expanded(&s, g)); + // ⌘G with a colour labels the group and every layer in it, like Layer › Layer Color. + s.undo(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let g = id(s.execute("layer.groupLayers", json!({"color": "green"})).unwrap()); + assert!(!expanded(&s, g)); + for l in [g, a, b] { + assert_eq!(layer(&s, l).label, LabelColor::Green); + } + } + + #[test] + fn below_puts_the_new_group_under_the_active_layer() { + let (mut s, a, b) = session(); + let bg = top(&s)[0]; + let g = id(s.execute("layer.new.group", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s), vec![bg, a, g, b]); + // Inside a group it stays inside, under the active layer. + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + let outer = id(s.execute("layer.groupLayers", json!({})).unwrap()); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + let inner = id(s.execute("layer.new.group", json!({"position": "below"})).unwrap()); + let children: Vec = layer(&s, outer).children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![inner, b]); + // Nothing goes under the Background: the group goes above it instead. + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + let g2 = id(s.execute("layer.new.group", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s)[..2], [bg, g2]); + // With no active layer it goes to the top, like a plain New Group. + s.execute("select.deselectLayers", json!({})).unwrap(); + let g3 = id(s.execute("layer.new.group", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s).last(), Some(&g3)); + } + + /// A group added inside a clipping mask (⌘-click under a clipped layer, or a plain New Group + /// on the base) joins the mask, as in Photoshop: the clipped layers keep their base and stay + /// visible, and nothing needs recompositing. + #[test] + fn a_group_added_inside_a_clipping_mask_joins_it() { + let (mut s, a, b) = session(); + s.execute("layer.setProps", json!({"layer": b.0, "clipped": true})).unwrap(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + let g = id(s.execute("layer.new.group", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s)[1..], [a, g, b]); + assert!(layer(&s, g).clipped, "under a clipped layer the group is clipped too"); + assert_eq!(s.active().unwrap().last_damage, Some(photocraft_geom::Rect::EMPTY), "B still clips to A"); + // On the base itself: the new group goes between A and its clipped layers. + s.undo(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + assert_eq!(top(&s)[1..], [a, g, b]); + assert!(layer(&s, g).clipped); + // Outside a clipping mask nothing changes. + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + assert!(!layer(&s, g).clipped); + } + + #[test] + fn bad_options_fail_without_touching_the_document() { + let (mut s, a, b) = session(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + for cmd in ["layer.new.group", "layer.new.groupFromLayers", "layer.groupLayers"] { + let mut bad = vec![ + json!({"name": 7}), + json!({"color": 5}), + json!({"color": "pink"}), + json!({"color": "Red"}), + json!({"mode": ""}), + json!({"mode": 3}), + json!({"mode": "sparkle"}), + json!({"opacity": "x"}), + json!({"opacity": [50]}), + ]; + if cmd == "layer.new.group" { + bad.push(json!({"position": "yes"})); + } + for p in bad { + let before = (doc(&s).clone(), s.active().unwrap().revision, steps(&s)); + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p} must be refused"); + assert_eq!((doc(&s).clone(), s.active().unwrap().revision, steps(&s)), before, "{cmd} {p} changed nothing"); + } + // A huge but finite opacity clamps to 100 % instead of failing. + let g = id(s.execute(cmd, json!({"opacity": f64::MAX})).unwrap()); + assert_eq!(layer(&s, g).opacity, 1.0); + s.undo(); + } + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index e4c93c7cbc..dc681b7d8e 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -21,6 +21,8 @@ pub fn session(s: &Session) -> Value { })).collect::>(), "foreground": s.tools.foreground, "background": s.tools.background, + // History panel › History Options… (`history.options`). + "historyOptions": crate::history_options_cmds::current(s), }) } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 6799640bad..bf3560eb9f 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -331,14 +331,25 @@ fn independent_of_document(id: &str) -> bool { } #[cfg(not(target_arch = "wasm32"))] -fn read_path(path: &str) -> Result> { - // Bounded reads, and a clear error for a file larger than memory (#375). - photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +fn import_path( + name: &str, + path: &str, + opts: &photocraft_io::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { + // Bounded reads, and a clear error for a file larger than memory (#375); a PSD/PSB is + // parsed from the open file instead of being read whole. + photocraft_io::import_file_for_open_with(name, std::path::Path::new(path), opts, ctl) } #[cfg(target_arch = "wasm32")] -fn read_path(path: &str) -> Result> { - Err(EngineError::Other(format!("cannot open paths on the web: {path}"))) +fn import_path( + _: &str, + path: &str, + _: &photocraft_io::ImportOptions, + _: &photocraft_raster::Interrupt, +) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { + Err(photocraft_io::IoError::Read(format!("cannot open paths on the web: {path}"))) } /// Run a job-capable command's heavy part. `work` runs on a worker thread when the command was @@ -555,17 +566,19 @@ impl Session { false, move |ctx| { ctx.progress(0.0, "Reading"); - let bytes = match source { - OpenSource::Bytes(b) => b, - OpenSource::Path(p) => Arc::new(read_path(&p)?), - }; - ctx.check()?; - ctx.progress(0.02, "Decoding"); - let imported = - ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)).map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - })?; + let imported = match source { + OpenSource::Bytes(bytes) => { + ctx.check()?; + ctx.progress(0.02, "Decoding"); + ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)) + } + // A PSD/PSB is decoded straight from the file, which is never read whole. + OpenSource::Path(p) => ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)), + } + .map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + })?; ctx.progress(0.95, "Preparing layers"); warm_layer_bounds(&imported.0.document, || ctx.check())?; // Type layers need the shared text engine, whose first use waits for the diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 8cbed9c425..1406e0a384 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -68,7 +68,7 @@ pub fn parse(text: &str) -> Result { return Err("not a keyboard shortcut set (.kys): larger than 4 MiB".into()); } let text = text.trim_start_matches('\u{feff}'); - let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a keyboard shortcut set (.kys): {e}"))?; + let doc = photocraft_io::xml::parse(text, roxmltree::ParsingOptions::default()).map_err(|e| format!("not a keyboard shortcut set (.kys): {e}"))?; let root = doc.root_element(); if root.tag_name().name() != "photoshop-keyboard-shortcuts" { return Err("not a keyboard shortcut set (.kys): no root".into()); @@ -385,6 +385,15 @@ mod tests { assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new())); } + /// A set nested 20 000 deep overflowed the XML parser's recursion (an abort nothing can + /// catch); it is an error now. The small stack makes a regression abort instead of passing. + #[test] + fn deeply_nested_sets_are_an_error_not_a_stack_overflow() { + let xml = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || parse(&xml).err()).unwrap().join().unwrap(); + assert!(e.as_deref().is_some_and(|m| m.contains("nested")), "{e:?}"); + } + #[test] fn a_row_with_the_default_and_a_function_key_keeps_both() { let xml = diff --git a/crates/engine/src/layer_place.rs b/crates/engine/src/layer_place.rs new file mode 100644 index 0000000000..3cb9c1e854 --- /dev/null +++ b/crates/engine/src/layer_place.rs @@ -0,0 +1,113 @@ +//! Where a new layer goes, as in Photoshop. Above the active layer, or as the top layer inside the +//! active group when that group is open (an artboard, open or closed, when the layer's content is +//! on the board); with nothing active, at the top of the stack. A layer that lands inside a clipping mask joins it: it is clipped when the layer +//! just above its slot is clipped, the rule Photoshop follows on every insert (#2203), so a new +//! layer (or group) between a base and its clipped layers never becomes their (empty) base. +//! Artboards are never nested or clipped: a new one goes to the top level. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, LayerPath, MAX_GROUP_DEPTH}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Place { + /// New Layer, New Group, new adjustment and fill layers: inside the anchor when it is an open + /// group (or an artboard the content is on), else directly above it. A locked group takes the + /// layer too, as in Photoshop (checked with Lock All): the layer is locked through it. + Auto, + /// [`Place::Auto`] for a layer that is edited right after it is made (Type, Shape, Place's + /// transform box, Paste, the Gradient tool's live gradient, video layers, Render and Vanishing + /// Point drawing, a new frame): never inside a group with a lock of its own (all, pixels, + /// transparency or position) that would refuse that edit; the layer goes above the group. + AutoEditable, + /// Directly above the anchor, never inside it: a layer made from another layer, or the second + /// and later of several new layers. + Above, + /// Directly below the anchor (⌘-click on the Layers panel's New Layer or New Group button). Never + /// under the Background, where it goes above instead. + Below, +} + +/// The slot `layer` would take: its parent's path (empty for the root) and its index among the +/// parent's children (bottom to top). A missing or stale `anchor` means the top of the stack. +pub fn insertion_point(doc: &Document, anchor: Option, layer: &Layer, place: Place) -> (LayerPath, usize) { + let root_top = (Vec::new(), doc.layers.len()); + let Some(path) = anchor.and_then(|a| doc.path_of(a)) else { return root_top }; + // An artboard stays at the top level: beside the anchor's top-level layer. + let path = if layer.artboard().is_some() { path.get(..1).map(<[usize]>::to_vec).unwrap_or_default() } else { path }; + let (Some((&k, parent)), Some(target)) = (path.split_last(), doc.layer_at(&path)) else { return root_top }; + let locks = &target.locks; + let editable = !(locks.all || locks.pixels || locks.transparency || locks.position); + match place { + Place::Auto | Place::AutoEditable if takes_new_layers(target, path.len(), layer) && (place == Place::Auto || editable) => { + (path.clone(), target.children().map_or(0, <[Layer]>::len)) + } + Place::Below if crate::background_cmds::background_id(doc) != Some(target.id) => (parent.to_vec(), k), + _ => (parent.to_vec(), k + 1), + } +} + +/// Inserts `layer` at its [`insertion_point`], clipped when it lands inside a clipping mask (a +/// flag it already has is kept unless nothing is below it to clip to; an artboard is never +/// clipped); returns its id. Never fails: a slot that can't be reached puts +/// the layer on top of the stack. +pub fn insert_new(doc: &mut Document, anchor: Option, mut layer: Layer, place: Place) -> LayerId { + let id = layer.id; + let (parent, index) = insertion_point(doc, anchor, &layer, place); + let sib = if parent.is_empty() { Some(&mut doc.layers) } else { doc.layer_at_mut(&parent).and_then(Layer::children_mut) }; + if let Some(sib) = sib { + let mut index = index.min(sib.len()); + if layer.artboard().is_some() { + // Past the clipped layers above the slot, so their clipping mask stays whole. + while sib.get(index).is_some_and(|above| above.clipped) { + index += 1; + } + layer.clipped = false; + } else if sib.get(index).is_some_and(|above| above.clipped) { + layer.clipped = true; + } else if index == 0 { + // Nothing below to clip to (a copied clipped layer pasted into an empty group). + layer.clipped = false; + } + sib.insert(index, layer); + } else { + doc.layers.push(layer); + } + id +} + +/// Does the group `target` at depth `depth` (its path length) take a new `layer` as its top +/// child? An open group or an artboard does, locked or not (Photoshop puts a new layer inside a +/// group locked with Lock All), unless nesting would pass [`MAX_GROUP_DEPTH`]. An artboard +/// takes a layer with content only when that content is centred on the board, as callers place +/// it for the canvas (Place, Paste, a Type or Shape click): elsewhere the board would clip it. +fn takes_new_layers(target: &Layer, depth: usize, layer: &Layer) -> bool { + let LayerContent::Group(g) = &target.content else { return false }; + let on_board = |rect: photocraft_geom::Rect| { + crate::layer_multi_cmds::layer_bounds(layer).is_none_or(|b| { + let (cx, cy) = (b.x0 / 2 + b.x1 / 2, b.y0 / 2 + b.y1 / 2); + rect.contains(cx, cy) + }) + }; + let open = match &g.artboard { + Some(board) => on_board(board.rect), + None => g.expanded, + }; + open && layer.artboard().is_none() && depth + subtree_depth(layer) <= MAX_GROUP_DEPTH +} + +/// Group nesting inside `layer` itself: 0 for a layer or an empty group, as +/// [`Document::max_group_depth`] counts it. Explicit stack: pasted groups can be deep. +fn subtree_depth(layer: &Layer) -> usize { + let mut max = 0; + let mut stack: Vec<(&Layer, usize)> = vec![(layer, 0)]; + while let Some((l, depth)) = stack.pop() { + if let Some(children) = l.children().filter(|c| !c.is_empty()) { + max = max.max(depth + 1); + stack.extend(children.iter().map(|c| (c, depth + 1))); + } + } + max +} + +#[cfg(test)] +#[path = "layer_place_tests.rs"] +mod tests; diff --git a/crates/engine/src/layer_place_tests.rs b/crates/engine/src/layer_place_tests.rs new file mode 100644 index 0000000000..5a84035426 --- /dev/null +++ b/crates/engine/src/layer_place_tests.rs @@ -0,0 +1,357 @@ +use photocraft_doc::{Document, Layer, LayerContent, LayerId, MAX_GROUP_DEPTH}; +use serde_json::{Value, json}; + +use super::*; +use crate::Session; + +/// Background, then `A` and `B` (`B` active). +fn session() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let a = id(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()); + let b = id(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()); + (s, a, b) +} + +fn id(v: Value) -> LayerId { + LayerId(v["layer"].as_u64().unwrap()) +} + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn top(s: &Session) -> Vec { + doc(s).layers.iter().map(|l| l.id).collect() +} + +fn children(s: &Session, group: LayerId) -> Vec { + doc(s).layer(group).unwrap().children().unwrap().iter().map(|l| l.id).collect() +} + +fn set_expanded(s: &mut Session, group: LayerId, expanded: bool) { + s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": expanded})).unwrap(); +} + +fn select(s: &mut Session, layer: LayerId) { + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); +} + +#[test] +fn a_new_layer_goes_inside_an_open_active_group_and_above_a_closed_one() { + let (mut s, a, b) = session(); + select(&mut s, a); + let g = id(s.execute("layer.groupLayers", json!({})).unwrap()); + // A group made from layers starts closed: the new layer goes above it. + let above = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(top(&s)[1..], [g, above, b]); + assert_eq!(children(&s, g), vec![a]); + // Opened, it takes the new layer as its top layer. + select(&mut s, g); + set_expanded(&mut s, g, true); + let inside = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(children(&s, g), vec![a, inside]); + // A new empty group is open, so the next new layer goes into it (New Group, then New Layer). + s.execute("select.deselectLayers", json!({})).unwrap(); + let empty = id(s.execute("layer.new.group", json!({})).unwrap()); + let first = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(children(&s, empty), vec![first]); + // The same goes for the other new layers: adjustment, fill, type and shape layers. + select(&mut s, empty); + for (cmd, p) in [ + ("layer.newAdjustmentLayer.invert", json!({})), + ("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})), + ("type.create", json!({"x": 2, "y": 10, "text": "Hi"})), + ("shape.create", json!({"kind": "rect", "rect": [1, 1, 5, 5]})), + ] { + select(&mut s, empty); + let l = id(s.execute(cmd, p).unwrap()); + assert_eq!(children(&s, empty).last(), Some(&l), "{cmd} goes inside the open group"); + } +} + +#[test] +fn artboards_and_locked_groups_take_new_layers() { + let (mut s, _, b) = session(); + let board = id(s.execute("layer.new.artboard", json!({"rect": [0, 0, 16, 16]})).unwrap()); + set_expanded(&mut s, board, false); + select(&mut s, board); + let l = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(doc(&s).artboard_of(l), Some(board), "an artboard takes the layer even when closed"); + // Photoshop puts a new layer inside an open group locked with Lock All too; the layer is + // locked through the group. + select(&mut s, b); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + s.execute("layer.setProps", json!({"layer": g.0, "locks": {"all": true}})).unwrap(); + select(&mut s, g); + let inside = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(children(&s, g), vec![inside]); + assert!(doc(&s).effective_locks(inside).all); +} + +#[test] +fn below_puts_the_new_layer_under_the_active_layer() { + let (mut s, a, b) = session(); + let bg = top(&s)[0]; + let l = id(s.execute("layer.new.layer", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s), vec![bg, a, l, b]); + // Never under the Background, and never inside an open group. + select(&mut s, bg); + let l2 = id(s.execute("layer.new.layer", json!({"position": "below"})).unwrap()); + assert_eq!(top(&s)[..2], [bg, l2]); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + let l3 = id(s.execute("layer.new.layer", json!({"position": "below"})).unwrap()); + assert!(children(&s, g).is_empty()); + let root = top(&s); + assert_eq!(root.iter().position(|x| *x == l3).unwrap() + 1, root.iter().position(|x| *x == g).unwrap()); + assert!(s.execute("layer.new.layer", json!({"position": "yes"})).is_err()); +} + +/// The clipping rule Photoshop follows on every insert (#2203): a new layer is clipped exactly +/// when the layer just above its slot is clipped. +#[test] +fn a_new_layer_joins_the_clipping_mask_it_lands_in() { + let (mut s, base, c1) = session(); + let c2 = id(s.execute("layer.new.layer", json!({"name": "C2"})).unwrap()); + for c in [c1, c2] { + s.execute("layer.setProps", json!({"layer": c.0, "clipped": true})).unwrap(); + } + let clipped = |s: &Session, l: LayerId| doc(s).layer(l).unwrap().clipped; + // On the base: between the base and its clipped layers. + select(&mut s, base); + let on_base = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert!(clipped(&s, on_base)); + // In the middle of the chain. + select(&mut s, c1); + let mid = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert!(clipped(&s, mid)); + // Above the top clipped layer it is outside the mask. + select(&mut s, c2); + let above = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert!(!clipped(&s, above)); + // ⌘-click New Layer under a clipped layer joins it too; under the base it does not. + select(&mut s, c2); + let l = id(s.execute("layer.new.layer", json!({"position": "below"})).unwrap()); + assert!(clipped(&s, l)); + select(&mut s, base); + let l = id(s.execute("layer.new.layer", json!({"position": "below"})).unwrap()); + assert!(!clipped(&s, l)); + // A group joins as well (Photoshop clips groups too), and so do the other new layers. + select(&mut s, base); + let l = id(s.execute("layer.new.group", json!({})).unwrap()); + assert!(clipped(&s, l)); + select(&mut s, base); + let l = id(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()); + assert!(clipped(&s, l)); + // A layer made from a clipped layer (Layer via Copy) stays in its mask. + select(&mut s, c1); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let l = id(s.execute("layer.new.layerViaCopy", json!({})).unwrap()); + assert!(clipped(&s, l)); +} + +#[test] +fn a_pasted_layer_goes_into_an_open_group() { + let (mut s, a, _) = session(); + // A whole pixel layer on the clipboard (no selection). + select(&mut s, a); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let into = id(s.execute("layer.new.group", json!({"name": "Into"})).unwrap()); + let pasted = id(s.execute("edit.paste", json!({})).unwrap()); + assert_eq!(children(&s, into), vec![pasted]); +} + +/// The second and later of several new layers stack above the first, even when the first is an +/// open group: it must not swallow them. +#[test] +fn later_layers_of_a_batch_go_above_the_first() { + let (s, _, b) = session(); + let mut d = doc(&s).clone(); + let first = insert_new(&mut d, Some(b), Layer::group("open", vec![]), Place::Auto); + let next = Layer::raster("next", d.pixel_format()); + let second = insert_new(&mut d, Some(first), next, Place::Above); + let root: Vec = d.layers.iter().map(|l| l.id).collect(); + assert_eq!(root[root.len() - 2..], [first, second]); + assert!(d.layer(first).unwrap().children().unwrap().is_empty()); +} + +#[test] +fn nesting_stops_at_the_group_depth_limit() { + let mut doc = Document::new("deep", photocraft_doc::Size::new(8, 8), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + // A chain of open groups as deep as allowed, with a layer at the bottom. + let mut inner = Layer::raster("deep", doc.pixel_format()); + let deep_id = inner.id; + let mut ids = Vec::new(); + for i in 0..MAX_GROUP_DEPTH { + let g = Layer::group(format!("g{i}"), vec![inner]); + ids.push(g.id); + inner = g; + } + doc.layers.push(inner); + assert_eq!(doc.max_group_depth(), MAX_GROUP_DEPTH); + let innermost = *ids.first().unwrap(); + // A plain layer fits beside `deep`, inside the innermost group (the depth stays the same)… + let layer = Layer::raster("fits", doc.pixel_format()); + let l = insert_new(&mut doc, Some(innermost), layer, Place::Auto); + assert_eq!(doc.max_group_depth(), MAX_GROUP_DEPTH); + assert!(doc.layer(innermost).unwrap().children().unwrap().iter().any(|c| c.id == l)); + // …but a group with a layer in it would go one deeper: it goes beside the group instead. + let nested = Layer::group("nested", vec![Layer::raster("x", doc.pixel_format())]); + let n = insert_new(&mut doc, Some(innermost), nested, Place::Auto); + assert_eq!(doc.max_group_depth(), MAX_GROUP_DEPTH); + assert!(!doc.layer(innermost).unwrap().children().unwrap().iter().any(|c| c.id == n)); + assert!(doc.layer(deep_id).is_some()); +} + +#[test] +fn a_missing_or_stale_anchor_means_the_top_of_the_stack() { + let (s, _, _) = session(); + let mut d = doc(&s).clone(); + for anchor in [None, Some(LayerId(u64::MAX))] { + for place in [Place::Auto, Place::Above, Place::Below] { + let layer = Layer::raster("x", d.pixel_format()); + let l = insert_new(&mut d, anchor, layer, place); + assert_eq!(d.layers.last().map(|x| x.id), Some(l), "{anchor:?} {place:?}"); + } + } + assert!(matches!(d.layers.last().unwrap().content, LayerContent::Raster(_))); +} + +/// Any lock on an open group still lets New Layer, New Group and new adjustment and fill layers in, +/// as in Photoshop (checked with Lock All). A layer that is edited right after it is made (Type, +/// Shape, Paste, Render…) goes above a group whose lock would refuse that edit, +/// so it never comes out locked. +#[test] +fn locked_groups_take_new_layers_but_not_the_ones_edited_at_once() { + for locks in [json!({"all": true}), json!({"pixels": true}), json!({"transparency": true}), json!({"position": true})] { + let (mut s, _, b) = session(); + select(&mut s, b); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let g = id(s.execute("layer.new.group", json!({})).unwrap()); + s.execute("layer.setProps", json!({"layer": g.0, "locks": locks})).unwrap(); + for (cmd, p) in [ + ("layer.new.layer", json!({})), + ("layer.new.group", json!({})), + ("layer.newAdjustmentLayer.invert", json!({})), + ("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})), + ] { + select(&mut s, g); + let l = id(s.execute(cmd, p).unwrap()); + assert_eq!(children(&s, g).last(), Some(&l), "{cmd} goes inside the group locked with {locks}"); + } + for (cmd, p) in [ + ("type.create", json!({"x": 2, "y": 10, "text": "Hi"})), + ("shape.create", json!({"kind": "rect", "rect": [1, 1, 5, 5]})), + ("edit.paste", json!({})), + ("filter.render.tree", json!({})), + ("gradient.fill.create", json!({"from": [0, 0], "to": [16, 16]})), + ] { + select(&mut s, g); + let l = id(s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd} with {locks}: {e}"))); + assert!(!children(&s, g).contains(&l), "{cmd} stays out of the group locked with {locks}"); + assert_eq!(top(&s).iter().position(|x| *x == l), top(&s).iter().position(|x| *x == g).map(|i| i + 1), "{cmd} directly above the group"); + assert!(!doc(&s).effective_locks(l).any(), "{cmd}: the new layer is not locked"); + if cmd == "filter.render.tree" { + assert!(doc(&s).layer(l).unwrap().surface().unwrap().tile_count() > 0, "Render drew the tree with {locks}"); + } + } + } + // An open group inside a locked one still takes New Layer (the lock reaches it through the outer group). + let (mut s, _, b) = session(); + select(&mut s, b); + let outer = id(s.execute("layer.new.group", json!({})).unwrap()); + let inner = id(s.execute("layer.new.group", json!({})).unwrap()); + assert_eq!(children(&s, outer), vec![inner]); + s.execute("layer.setProps", json!({"layer": outer.0, "locks": {"pixels": true}})).unwrap(); + select(&mut s, inner); + let plain = id(s.execute("layer.new.layer", json!({})).unwrap()); + assert_eq!(children(&s, inner), vec![plain]); +} + +/// Stamp Visible goes directly above an open active group, never inside it: the stamp is the +/// composite already, and inside it the group's opacity, mode or mask would apply to it again. +/// With a layer inside a group active, it still lands above that layer, inside the group. +#[test] +fn stamp_visible_goes_above_an_open_group() { + let (mut s, a, b) = session(); + select(&mut s, a); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let g = id(s.execute("layer.groupLayers", json!({})).unwrap()); + set_expanded(&mut s, g, true); + s.execute("layer.setProps", json!({"layer": g.0, "opacity": 0.5})).unwrap(); + select(&mut s, g); + let stamp = id(s.execute("layer.stampVisible", json!({})).unwrap()); + assert_eq!(children(&s, g), vec![a], "the stamp stays out of the group"); + assert_eq!(top(&s)[1..], [g, stamp, b]); + // A layer inside the group active: the stamp goes above it, inside the group. + select(&mut s, a); + let inside = id(s.execute("layer.stampVisible", json!({})).unwrap()); + assert_eq!(children(&s, g), vec![a, inside]); +} + +/// An artboard takes a new layer whose content is centred on it, and a new empty layer; content +/// placed elsewhere (Place and Paste centre on the canvas) stays at the top level instead of +/// being clipped away by the board. +#[test] +fn an_artboard_takes_only_content_that_is_on_it() { + let (mut s, _, _) = session(); + let board = id(s.execute("layer.new.artboard", json!({"rect": [0, 0, 8, 8]})).unwrap()); + let mut d = doc(&s).clone(); + let raster = |d: &Document, r: photocraft_geom::Rect| { + let mut l = Layer::raster("content", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(r, &[1.0, 0.0, 0.0, 1.0]); + l + }; + let on = raster(&d, photocraft_geom::Rect::new(1, 1, 5, 5)); + let on = insert_new(&mut d, Some(board), on, Place::Auto); + assert_eq!(d.artboard_of(on), Some(board)); + let off = raster(&d, photocraft_geom::Rect::new(10, 10, 16, 16)); + let off = insert_new(&mut d, Some(board), off, Place::Auto); + assert_eq!(d.artboard_of(off), None); + let root: Vec = d.layers.iter().map(|l| l.id).collect(); + assert_eq!(root.iter().position(|x| *x == off), root.iter().position(|x| *x == board).map(|i| i + 1)); +} + +/// An artboard is never nested or clipped: a copied board goes beside the anchor's top-level +/// layer, even after a first copy went into a group, and never joins a clipping mask. +#[test] +fn artboards_stay_at_the_top_level_and_unclipped() { + let (mut s, a, b) = session(); + let board = id(s.execute("layer.new.artboard", json!({"rect": [0, 0, 8, 8]})).unwrap()); + s.execute("layer.setProps", json!({"layer": b.0, "clipped": true})).unwrap(); + let mut d = doc(&s).clone(); + let copy = d.layer(board).unwrap().duplicate(); + // On a clipping base: at the top level above its clipping mask (B keeps A as its base), not + // clipped. + let c = insert_new(&mut d, Some(a), copy.duplicate(), Place::Auto); + let root: Vec = d.layers.iter().map(|l| l.id).collect(); + let at = |id: LayerId| root.iter().position(|x| *x == id).unwrap(); + assert_eq!((at(b), at(c)), (at(a) + 1, at(a) + 2)); + assert!(!d.layer(c).unwrap().clipped); + // After a layer that went into an open group: beside the group, not inside it. + let g = insert_new(&mut d, Some(a), Layer::group("open", vec![]), Place::Above); + let first = Layer::raster("first", d.pixel_format()); + let first = insert_new(&mut d, Some(g), first, Place::Auto); + assert!(d.layer(g).unwrap().children().unwrap().iter().any(|l| l.id == first)); + let second = insert_new(&mut d, Some(first), copy, Place::Above); + assert!(d.layers.iter().any(|l| l.id == second), "the board is not nested in the group"); + assert_eq!(d.artboards().len(), 3); +} + +/// A copied clipped layer pasted into an empty group has nothing below it to clip to. +#[test] +fn a_clipped_layer_with_nothing_below_is_released() { + let (s, _, b) = session(); + let mut d = doc(&s).clone(); + let g = insert_new(&mut d, Some(b), Layer::group("empty", vec![]), Place::Auto); + let mut l = Layer::raster("was clipped", d.pixel_format()); + l.clipped = true; + let l = insert_new(&mut d, Some(g), l, Place::Auto); + assert!(d.layer(g).unwrap().children().unwrap().iter().any(|c| c.id == l)); + assert!(!d.layer(l).unwrap().clipped); +} diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 7d0e337d34..e1a7f3755c 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -610,248 +610,260 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { /// parameter units. Absent (or unparseable) keys leave the effect unchanged, so /// a dialog round trip never touches what it doesn't model: an imported /// gradient stroke keeps its gradient while its opacity is edited. +fn overlay_color(p: &Value, k: &str, c: &mut Color) { + if let Some(x) = color_of(p, k) { + *c = x; + } +} + +fn overlay_contour(p: &Value, key: &str, c: &mut Contour) { + if let Some(x) = p.get(key).and_then(contour_from_param) { + *c = x; + } +} + +fn overlay_bool(p: &Value, key: &str, b: &mut bool) { + if let Some(x) = b_of(p, key) { + *b = x; + } +} + +fn overlay_common(c: &mut FxCommon, p: &Value) { + if let Some(x) = b_of(p, "enabled") { + c.enabled = x; + } + if let Some(x) = p.get("blend").and_then(Value::as_str).and_then(blend_from_str) { + c.blend = x; + } + if let Some(x) = f_of(p, "opacity") { + c.opacity = (x / 100.0).clamp(0.0, 1.0); + } +} + +fn overlay_shadow(s: &mut Shadow, p: &Value, inner: bool) { + overlay_common(&mut s.common, p); + overlay_color(p, "color", &mut s.color); + if let Some(x) = f_of(p, "angle") { + s.angle = x; + } + if let Some(x) = b_of(p, "useGlobalLight") { + s.use_global_light = x; + } + if let Some(x) = f_of(p, "distance") { + s.distance = x; + } + if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) { + s.spread = (x / 100.0).clamp(0.0, 1.0); + } + if let Some(x) = f_of(p, "size") { + s.size = x; + } + overlay_contour(p, "contour", &mut s.contour); + overlay_bool(p, "antiAlias", &mut s.anti_alias); + if let Some(x) = f_of(p, "noise") { + s.noise = (x / 100.0).clamp(0.0, 1.0); + } + if !inner && let Some(x) = b_of(p, "knocksOut") { + s.knocks_out = x; + } +} + +fn overlay_glow(g: &mut Glow, p: &Value, inner: bool) { + overlay_common(&mut g.common, p); + if let Some(c) = color_of(p, "color") { + g.paint = FxPaint::Color(c); + } + if let Some(t) = p.get("technique").and_then(Value::as_str) { + g.technique = if t == "precise" { GlowTechnique::Precise } else { GlowTechnique::Softer }; + } + if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) { + g.spread = (x / 100.0).clamp(0.0, 1.0); + } + if let Some(x) = f_of(p, "size") { + g.size = x; + } + overlay_contour(p, "contour", &mut g.contour); + overlay_bool(p, "antiAlias", &mut g.anti_alias); + if let Some(x) = f_of(p, "range") { + g.range = (x / 100.0).clamp(0.01, 1.0); + } + if let Some(x) = f_of(p, "noise") { + g.noise = (x / 100.0).clamp(0.0, 1.0); + } + if inner && let Some(s) = p.get("source").and_then(Value::as_str) { + g.source = if s == "center" { GlowSource::Center } else { GlowSource::Edge }; + } +} + +fn overlay_stroke(s: &mut StrokeFx, p: &Value) { + overlay_common(&mut s.common, p); + if let Some(x) = f_of(p, "size") { + s.size = x; + } + if let Some(pos) = p.get("position").and_then(Value::as_str) { + s.position = match pos { + "inside" => StrokePosition::Inside, + "center" => StrokePosition::Center, + _ => StrokePosition::Outside, + }; + } + overlay_bool(p, "overprint", &mut s.overprint); + if p.get("from").is_some() { + s.paint = FxPaint::Gradient(gradient(p)); + } else if let Some(c) = color_of(p, "color") { + s.paint = FxPaint::Color(c); + } +} + +fn overlay_pattern(fx: &mut Effect, p: &Value) { + let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = fx else { return }; + overlay_common(common, p); + if let Some(x) = p.get("pattern").and_then(Value::as_str) { + *name = x.to_string(); + *id = String::new(); + } + if let Some(x) = f_of(p, "scale") { + *scale = (x / 100.0).clamp(0.01, 10.0); + } + if let Some(x) = f_of(p, "angle") { + *angle = x; + } + if let Some(x) = b_of(p, "link") { + *link = x; + } + if let Some(x) = f_of(p, "phaseX") { + phase.0 = x; + } + if let Some(x) = f_of(p, "phaseY") { + phase.1 = x; + } +} + +fn overlay_satin(s: &mut Satin, p: &Value) { + overlay_common(&mut s.common, p); + overlay_color(p, "color", &mut s.color); + if let Some(x) = f_of(p, "angle") { + s.angle = x; + } + if let Some(x) = f_of(p, "distance") { + s.distance = x; + } + if let Some(x) = f_of(p, "size") { + s.size = x; + } + if let Some(x) = b_of(p, "invert") { + s.invert = x; + } + overlay_contour(p, "contour", &mut s.contour); + overlay_bool(p, "antiAlias", &mut s.anti_alias); +} + +fn overlay_bevel(bv: &mut Bevel, p: &Value) { + if let Some(x) = b_of(p, "enabled") { + bv.enabled = x; + } + if let Some(s) = p.get("style").and_then(Value::as_str) { + bv.style = match s { + "outer" => BevelStyle::OuterBevel, + "emboss" => BevelStyle::Emboss, + "pillow" => BevelStyle::PillowEmboss, + "stroke" => BevelStyle::StrokeEmboss, + _ => BevelStyle::InnerBevel, + }; + } + if let Some(t) = p.get("technique").and_then(Value::as_str) { + bv.technique = match t { + "chiselHard" => BevelTechnique::ChiselHard, + "chiselSoft" => BevelTechnique::ChiselSoft, + _ => BevelTechnique::Smooth, + }; + } + if let Some(x) = f_of(p, "depth") { + bv.depth = x / 100.0; + } + if let Some(d) = p.get("direction").and_then(Value::as_str) { + bv.up = d != "down"; + } + if let Some(x) = f_of(p, "size") { + bv.size = x; + } + if let Some(x) = f_of(p, "soften") { + bv.soften = x; + } + if let Some(x) = f_of(p, "angle") { + bv.angle = x; + } + if let Some(x) = f_of(p, "altitude") { + bv.altitude = x; + } + if let Some(x) = b_of(p, "useGlobalLight") { + bv.use_global_light = x; + } + overlay_contour(p, "glossContour", &mut bv.gloss_contour); + overlay_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias); + if let Some(blend) = p.get("highlightBlend").and_then(Value::as_str).and_then(blend_from_str) { + bv.highlight.blend = blend; + } + if let Some(blend) = p.get("shadowBlend").and_then(Value::as_str).and_then(blend_from_str) { + bv.shadow.blend = blend; + } + overlay_color(p, "highlightColor", &mut bv.highlight_color); + overlay_color(p, "shadowColor", &mut bv.shadow_color); + if let Some(x) = f_of(p, "highlightOpacity") { + bv.highlight.opacity = (x / 100.0).clamp(0.0, 1.0); + } + if let Some(x) = f_of(p, "shadowOpacity") { + bv.shadow.opacity = (x / 100.0).clamp(0.0, 1.0); + } + match b_of(p, "contour") { + Some(false) => bv.contour = None, + Some(true) if bv.contour.is_none() => bv.contour = Some(photocraft_doc::BevelContour::default()), + _ => {} + } + if let Some(c) = &mut bv.contour { + overlay_contour(p, "contourName", &mut c.contour); + if let Some(x) = f_of(p, "contourRange") { + c.range = (x / 100.0).clamp(0.01, 1.0); + } + overlay_bool(p, "contourAntiAlias", &mut c.anti_alias); + } + if let Some(t) = p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()) { + 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), + depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0), + invert: b(p, "textureInvert", false), + link: b(p, "textureLink", true), + phase: (0.0, 0.0), + }); + } +} + pub fn overlay_effect(fx: &mut Effect, p: &Value) { if crate::commands::check_f32_values("layer.layerStyle", p).is_err() { return; } - fn set_color(p: &Value, k: &str, c: &mut Color) { - if let Some(x) = color_of(p, k) { - *c = x; - } - } - fn set_contour(p: &Value, key: &str, c: &mut Contour) { - if let Some(x) = p.get(key).and_then(contour_from_param) { - *c = x; - } - } - fn set_bool(p: &Value, key: &str, b: &mut bool) { - if let Some(x) = b_of(p, key) { - *b = x; - } - } - fn common(c: &mut FxCommon, p: &Value) { - if let Some(x) = b_of(p, "enabled") { - c.enabled = x; - } - if let Some(x) = p.get("blend").and_then(Value::as_str).and_then(blend_from_str) { - c.blend = x; - } - if let Some(x) = f_of(p, "opacity") { - c.opacity = (x / 100.0).clamp(0.0, 1.0); - } - } - let inner_shadow = matches!(fx, Effect::InnerShadow(_)); - let inner_glow = matches!(fx, Effect::InnerGlow(_)); match fx { - Effect::DropShadow(s) | Effect::InnerShadow(s) => { - let inner = inner_shadow; - common(&mut s.common, p); - set_color(p, "color", &mut s.color); - if let Some(x) = f_of(p, "angle") { - s.angle = x; - } - if let Some(x) = b_of(p, "useGlobalLight") { - s.use_global_light = x; - } - if let Some(x) = f_of(p, "distance") { - s.distance = x; - } - if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) { - s.spread = (x / 100.0).clamp(0.0, 1.0); - } - if let Some(x) = f_of(p, "size") { - s.size = x; - } - set_contour(p, "contour", &mut s.contour); - set_bool(p, "antiAlias", &mut s.anti_alias); - if let Some(x) = f_of(p, "noise") { - s.noise = (x / 100.0).clamp(0.0, 1.0); - } - if !inner && let Some(x) = b_of(p, "knocksOut") { - s.knocks_out = x; - } + Effect::DropShadow(s) => overlay_shadow(s, p, false), + Effect::InnerShadow(s) => overlay_shadow(s, p, true), + Effect::OuterGlow(g) => overlay_glow(g, p, false), + Effect::InnerGlow(g) => overlay_glow(g, p, true), + Effect::Stroke(s) => overlay_stroke(s, p), + Effect::ColorOverlay { common, color } => { + overlay_common(common, p); + overlay_color(p, "color", color); } - Effect::OuterGlow(g) | Effect::InnerGlow(g) => { - let inner = inner_glow; - common(&mut g.common, p); - if let Some(c) = color_of(p, "color") { - g.paint = FxPaint::Color(c); - } - if let Some(t) = p.get("technique").and_then(Value::as_str) { - g.technique = if t == "precise" { GlowTechnique::Precise } else { GlowTechnique::Softer }; - } - if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) { - g.spread = (x / 100.0).clamp(0.0, 1.0); - } - if let Some(x) = f_of(p, "size") { - g.size = x; - } - set_contour(p, "contour", &mut g.contour); - set_bool(p, "antiAlias", &mut g.anti_alias); - if let Some(x) = f_of(p, "range") { - g.range = (x / 100.0).clamp(0.01, 1.0); - } - if let Some(x) = f_of(p, "noise") { - g.noise = (x / 100.0).clamp(0.0, 1.0); - } - if inner && let Some(s) = p.get("source").and_then(Value::as_str) { - g.source = if s == "center" { GlowSource::Center } else { GlowSource::Edge }; - } - } - Effect::Stroke(s) => { - common(&mut s.common, p); - if let Some(x) = f_of(p, "size") { - s.size = x; - } - if let Some(pos) = p.get("position").and_then(Value::as_str) { - s.position = match pos { - "inside" => StrokePosition::Inside, - "center" => StrokePosition::Center, - _ => StrokePosition::Outside, - }; - } - set_bool(p, "overprint", &mut s.overprint); - if p.get("from").is_some() { - s.paint = FxPaint::Gradient(gradient(p)); - } else if let Some(c) = color_of(p, "color") { - s.paint = FxPaint::Color(c); - } - } - Effect::ColorOverlay { common: c, color } => { - common(c, p); - set_color(p, "color", color); - } - Effect::GradientOverlay { common: c, gradient: g, dither, interpolation } => { - common(c, p); - gradient_overlay::overlay(g, interpolation, p); + Effect::GradientOverlay { common, gradient, dither, interpolation } => { + overlay_common(common, p); + gradient_overlay::overlay(gradient, interpolation, p); if let Some(x) = b_of(p, "dither") { *dither = x; } } - Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => { - common(c, p); - if let Some(x) = p.get("pattern").and_then(Value::as_str) { - *name = x.to_string(); - *id = String::new(); - } - if let Some(x) = f_of(p, "scale") { - *scale = (x / 100.0).clamp(0.01, 10.0); - } - if let Some(x) = f_of(p, "angle") { - *angle = x; - } - if let Some(x) = b_of(p, "link") { - *link = x; - } - if let Some(x) = f_of(p, "phaseX") { - phase.0 = x; - } - if let Some(x) = f_of(p, "phaseY") { - phase.1 = x; - } - } - Effect::Satin(s) => { - common(&mut s.common, p); - set_color(p, "color", &mut s.color); - if let Some(x) = f_of(p, "angle") { - s.angle = x; - } - if let Some(x) = f_of(p, "distance") { - s.distance = x; - } - if let Some(x) = f_of(p, "size") { - s.size = x; - } - if let Some(x) = b_of(p, "invert") { - s.invert = x; - } - set_contour(p, "contour", &mut s.contour); - set_bool(p, "antiAlias", &mut s.anti_alias); - } - Effect::BevelEmboss(bv) => { - if let Some(x) = b_of(p, "enabled") { - bv.enabled = x; - } - if let Some(s) = p.get("style").and_then(Value::as_str) { - bv.style = match s { - "outer" => BevelStyle::OuterBevel, - "emboss" => BevelStyle::Emboss, - "pillow" => BevelStyle::PillowEmboss, - "stroke" => BevelStyle::StrokeEmboss, - _ => BevelStyle::InnerBevel, - }; - } - if let Some(t) = p.get("technique").and_then(Value::as_str) { - bv.technique = match t { - "chiselHard" => BevelTechnique::ChiselHard, - "chiselSoft" => BevelTechnique::ChiselSoft, - _ => BevelTechnique::Smooth, - }; - } - if let Some(x) = f_of(p, "depth") { - bv.depth = x / 100.0; - } - if let Some(d) = p.get("direction").and_then(Value::as_str) { - bv.up = d != "down"; - } - if let Some(x) = f_of(p, "size") { - bv.size = x; - } - if let Some(x) = f_of(p, "soften") { - bv.soften = x; - } - if let Some(x) = f_of(p, "angle") { - bv.angle = x; - } - if let Some(x) = f_of(p, "altitude") { - bv.altitude = x; - } - if let Some(x) = b_of(p, "useGlobalLight") { - bv.use_global_light = x; - } - set_contour(p, "glossContour", &mut bv.gloss_contour); - set_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias); - // Keep imported PSD shading settings when a command edits other bevel fields. - if let Some(blend) = p.get("highlightBlend").and_then(Value::as_str).and_then(blend_from_str) { - bv.highlight.blend = blend; - } - if let Some(blend) = p.get("shadowBlend").and_then(Value::as_str).and_then(blend_from_str) { - bv.shadow.blend = blend; - } - set_color(p, "highlightColor", &mut bv.highlight_color); - set_color(p, "shadowColor", &mut bv.shadow_color); - if let Some(x) = f_of(p, "highlightOpacity") { - bv.highlight.opacity = (x / 100.0).clamp(0.0, 1.0); - } - if let Some(x) = f_of(p, "shadowOpacity") { - bv.shadow.opacity = (x / 100.0).clamp(0.0, 1.0); - } - // The Contour element: `contour` turns it on/off; its curve, Range and - // Anti-aliased edit the element in place, so an imported curve survives. - match b_of(p, "contour") { - Some(false) => bv.contour = None, - Some(true) if bv.contour.is_none() => bv.contour = Some(photocraft_doc::BevelContour::default()), - _ => {} - } - if let Some(c) = &mut bv.contour { - set_contour(p, "contourName", &mut c.contour); - if let Some(x) = f_of(p, "contourRange") { - c.range = (x / 100.0).clamp(0.01, 1.0); - } - set_bool(p, "contourAntiAlias", &mut c.anti_alias); - } - if let Some(t) = p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()) { - 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), - depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0), - invert: b(p, "textureInvert", false), - link: b(p, "textureLink", true), - phase: (0.0, 0.0), - }); - } - } + Effect::PatternOverlay { .. } => overlay_pattern(fx, p), + Effect::Satin(s) => overlay_satin(s, p), + Effect::BevelEmboss(bv) => overlay_bevel(bv, p), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 93e91922b6..c88ae3bfaf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,12 +18,14 @@ pub mod display_color; pub mod doc_path; pub mod filters; pub mod filters_ext; +mod group_options; pub mod guide_layout; pub mod hidden_target; pub mod inspect; pub mod installed_profiles; pub mod jobs; pub mod kys; +pub mod layer_place; pub mod layer_style; pub mod link_watch; pub mod new_layer; @@ -88,6 +90,7 @@ pub use cmd::file_cmds; pub use cmd::fill_cmds; pub use cmd::fill_key_cmds; pub use cmd::float_cmds; +pub use cmd::font_library_cmds; use cmd::frame_cmds; pub use cmd::fx_view_cmds; pub use cmd::fx_visibility_cmds; @@ -96,6 +99,7 @@ pub use cmd::gradient_fill_cmds; pub use cmd::group_view_cmds; pub use cmd::history_cmds; pub use cmd::history_menu_cmds; +pub use cmd::history_options_cmds; pub use cmd::history_select_cmds; pub use cmd::history_source_cmds; pub use cmd::image_cmds; @@ -160,9 +164,9 @@ pub use cmd::web_cmds; use cmd::wia_cmds; pub use doc_state::{CopiedStyle, DocState, PRECHECKED, ToolState}; pub use error::{EngineError, Result}; -pub(crate) use session::fix_selection; pub use session::move_item; use session::restore_target; +pub(crate) use session::{carry_visibility, fix_selection, visibility_changes}; /// The pixels of a raster layer (typically one a command just created), as an error instead /// of a panic if the layer has none. @@ -241,6 +245,9 @@ pub struct Session { /// A further route for File › Print when neither `lp` nor a CUPS server over IPP is /// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`. pub print_spooler: Option, + /// The printers `file.printers`, `file.print.printers` and the Print dialog list in place of + /// the system's (tests); see `print_cmds::PrinterSource`. `None` asks the spooler. + pub printer_source: Option, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index cb1b151aba..4cba7f0c52 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -35,10 +35,12 @@ pub(crate) struct NewLayer { name: Option, } -/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the -/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in. +/// `layer.new.layer`: a pixel layer where Photoshop puts a new one ([`crate::layer_place`]: above +/// the active layer, inside it when it is an open group, below it with `position: below`, the Layers +/// panel's ⌘/Ctrl-click), joining the clip group it lands in. pub fn run(s: &mut Session, p: &Value) -> Result { let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?; + let place = if below { crate::layer_place::Place::Below } else { crate::layer_place::Place::Auto }; let id = s.edit("New Layer", |doc, active| { let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); let mut layer = Layer::raster(name, doc.pixel_format()); @@ -51,11 +53,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); surface.fill_rect(bounds, &px); } - // Nothing may sit under the Background: below it the layer lands above it instead. - let id = match *active { - Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), - _ => doc.insert_above(*active, layer), - }; + let id = crate::layer_place::insert_new(doc, *active, layer, place); crate::clip_group::settle(doc, &[id], &Default::default(), clip); *active = Some(id); Ok(id) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 24cba73d4d..38401ff468 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -12,8 +12,9 @@ //! OS config directory; the web shell: browser storage) and restore it with //! [`Session::load_prefs_json`]. //! -//! Engine-side values are applied by [`Session::apply_prefs`]: the history state limit (every -//! open document), the effect-cache memory budget, and units for commands that report lengths. +//! Engine-side values are applied by [`Session::apply_prefs`]: the history state limit and +//! History Options › Allow Non-Linear History (every open document), the effect-cache memory +//! budget, and units for commands that report lengths. //! Colour settings (`colorSettings`) are stored with the colour state //! ([`crate::color_cmds::ColorSettings`]) and persisted in the same document. @@ -869,6 +870,9 @@ pub struct TypePrefs { pub font_preview: FontPreview, pub fill_new_type_layers_with_placeholder: bool, pub recent_fonts: u32, + /// Favourite font families, starred in the font menu (`type.font.favorite`), in the order + /// they were added. + pub favorite_fonts: Vec, } impl Default for TypePrefs { @@ -882,6 +886,7 @@ impl Default for TypePrefs { font_preview: FontPreview::Medium, fill_new_type_layers_with_placeholder: true, recent_fonts: 10, + favorite_fonts: Vec::new(), } } } @@ -998,6 +1003,24 @@ impl Default for Integrations { } } +/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either). +/// Photoshop's snapshot options come with #3634. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HistoryOptions { + /// Allow Non-Linear History: editing after selecting an earlier state keeps the later + /// states (the new step is appended at the end), and deleting a state deletes only it. + pub allow_non_linear_history: bool, + /// Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state. + pub make_layer_visibility_changes_undoable: bool, +} + +impl Default for HistoryOptions { + fn default() -> Self { + Self { allow_non_linear_history: false, make_layer_visibility_changes_undoable: true } + } +} + /// Edit › Menus: hidden items and item colours, by command id. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1083,6 +1106,8 @@ pub struct Preferences { pub enhanced_controls: EnhancedControls, pub raw_defaults: RawDefaults, pub integrations: Integrations, + /// History panel › History Options… (`history.options`). + pub history_options: HistoryOptions, /// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty /// string removes the default shortcut. pub shortcuts: BTreeMap, @@ -1221,9 +1246,15 @@ impl Preferences { self.shortcuts.clear(); return Ok(()); } + // The favourite fonts are the user's library, not a Type setting: resetting the Type + // section keeps them (only `type.favoriteFonts` itself, or Reset All, clears them). + let favorites = (path == "type").then(|| self.type_.favorite_fonts.clone()); let mut root = self.to_json(); set_path(&mut root, path, def)?; *self = serde_json::from_value(root).map_err(|e| e.to_string())?; + if let Some(favorites) = favorites { + self.type_.favorite_fonts = favorites; + } Ok(()) } @@ -1268,15 +1299,18 @@ impl Session { r } - /// Push engine-relevant preferences into live state: every document's history limit, the - /// transform and layer-effect cache budgets and the text engine's missing-glyph protection. + /// Push engine-relevant preferences into live state: every document's history limit and + /// linearity, the transform and layer-effect cache budgets and the text engine's + /// missing-glyph protection. pub fn apply_prefs(&mut self) { let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_budget_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; + let non_linear = self.prefs().history_options.allow_non_linear_history; for st in &mut self.docs { st.history.max_states = n; st.history.max_bytes = bytes; + st.history.non_linear = non_linear; st.trim_history(); } photocraft_compose::set_effect_cache_budget(budget << 20); diff --git a/crates/engine/src/prefs/cmds.rs b/crates/engine/src/prefs/cmds.rs index b8f92356da..982bf209ab 100644 --- a/crates/engine/src/prefs/cmds.rs +++ b/crates/engine/src/prefs/cmds.rs @@ -1,6 +1,6 @@ use super::customize::{keyboard_shortcuts, menus}; use super::validate::{SECTIONS, get_path, keyed}; -use super::*; +use super::{CommandSpec, EngineError, Map, Result, Session, ToolbarCustomization, Value, json}; // ------------------------------------------------------------------ commands diff --git a/crates/engine/src/prefs/customize.rs b/crates/engine/src/prefs/customize.rs index e6d76723c8..bfad131ba1 100644 --- a/crates/engine/src/prefs/customize.rs +++ b/crates/engine/src/prefs/customize.rs @@ -1,7 +1,7 @@ use super::cmds::bad; use super::files::{file_target, read_text_file, rename_path, writable, write_files_atomically}; use super::shortcuts::{BindKind, alternate_keys, bindable, bindable_items, conflicts, default_alternates, is_tool, normalize_shortcut, reserved_shortcut}; -use super::*; +use super::{MenuCustomization, Preferences, Result, Session, Value, json}; /// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts. pub(super) fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { @@ -108,65 +108,7 @@ pub(super) fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => &empty, Some(v) => v.as_object().ok_or_else(|| bad(cmd, "`alternates` maps command ids to lists of shortcuts"))?, }; - if !set.is_empty() || !alts.is_empty() { - let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); - changed = true; - // Reserved system keys can't be bound, by hand or from a file. - let reserved = |v: &Value| -> Option { - let keys = match v { - Value::String(s) => vec![s.clone()], - other => alternate_keys(other).unwrap_or_default(), - }; - keys.into_iter().find(|k| reserved_shortcut(k)) - }; - if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) { - return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned"))); - } - for (id, v) in set { - if !bindable().any(|(b, _)| b == id) && !allow_unknown { - return Err(bad(cmd, format!("unknown command `{id}`"))); - } - next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; - } - for (id, v) in alts { - if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown { - return Err(bad(cmd, format!("unknown command `{id}`"))); - } - next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; - } - // Like Photoshop, a shortcut moved to a new command is taken away from the old one, - // whether the old one held it as its main key or as an alternate (#3665). - if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { - let assigned: Vec<(&str, String)> = set - .iter() - .filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?))) - .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) - .collect(); - for (id, sc) in assigned { - for (c, def) in bindable() { - // Tools may share a key: they cycle with Shift, as Photoshop's groups do. - if is_tool(id) && is_tool(c) { - continue; - } - // Never strip a command that this same call is assigning: - // two entries for one key are a clash inside the call, which - // the returned conflicts list reports (#719). Stripping - // each other here unbound both silently. - if c == id || set.contains_key(c) || alts.contains_key(c) { - continue; - } - let main = next.shortcut(c, def).map(str::to_string); - if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { - next.shortcuts.insert(c.to_string(), String::new()); - } - let held = next.alternates(c, main.as_deref()); - if held.contains(&sc) { - next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect()); - } - } - } - } - } + changed |= apply_shortcut_changes(&mut next, p, set, alts, cmd)?; // Then the sets are saved, deleted or switched, from the bindings as they now stand. changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?; // The reply and the exports read the bindings as they will be; they are committed only once @@ -262,6 +204,75 @@ pub(super) fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { Ok(out) } +fn apply_shortcut_changes( + next: &mut Preferences, + p: &Value, + set: &serde_json::Map, + alts: &serde_json::Map, + cmd: &str, +) -> Result { + if set.is_empty() && alts.is_empty() { + return Ok(false); + } + let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); + // Reserved system keys can't be bound, by hand or from a file. + let reserved = |v: &Value| -> Option { + let keys = match v { + Value::String(s) => vec![s.clone()], + other => alternate_keys(other).unwrap_or_default(), + }; + keys.into_iter().find(|k| reserved_shortcut(k)) + }; + if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) { + return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned"))); + } + for (id, v) in set { + if !bindable().any(|(b, _)| b == id) && !allow_unknown { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; + } + for (id, v) in alts { + if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; + } + // Like Photoshop, a shortcut moved to a new command is taken away from the old one, + // whether the old one held it as its main key or as an alternate (#3665). + if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { + let assigned: Vec<(&str, String)> = set + .iter() + .filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?))) + .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) + .collect(); + for (id, sc) in assigned { + for (c, def) in bindable() { + // Tools may share a key: they cycle with Shift, as Photoshop's groups do. + if is_tool(id) && is_tool(c) { + continue; + } + // Never strip a command that this same call is assigning: + // two entries for one key are a clash inside the call, which + // the returned conflicts list reports (#719). Stripping + // each other here unbound both silently. + if c == id || set.contains_key(c) || alts.contains_key(c) { + continue; + } + let main = next.shortcut(c, def).map(str::to_string); + if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { + next.shortcuts.insert(c.to_string(), String::new()); + } + let held = next.alternates(c, main.as_deref()); + if held.contains(&sc) { + next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect()); + } + } + } + } + Ok(true) +} + /// Edit › Menus: hide/show items and give them colours. pub(super) fn menus(s: &mut Session, p: &Value) -> Result { let ids = |k: &str| -> Vec { diff --git a/crates/engine/src/prefs/files.rs b/crates/engine/src/prefs/files.rs index d24205228a..c58604e71d 100644 --- a/crates/engine/src/prefs/files.rs +++ b/crates/engine/src/prefs/files.rs @@ -1,5 +1,5 @@ use super::cmds::bad; -use super::*; +use super::{Result, Value}; /// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object /// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` → diff --git a/crates/engine/src/prefs/shortcuts.rs b/crates/engine/src/prefs/shortcuts.rs index 47ba5a5f83..337c8e3754 100644 --- a/crates/engine/src/prefs/shortcuts.rs +++ b/crates/engine/src/prefs/shortcuts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeMap, Value}; /// Most further keys one command may hold; Photoshop's own defaults use one. pub const MAX_ALTERNATES: usize = 8; diff --git a/crates/engine/src/prefs/store.rs b/crates/engine/src/prefs/store.rs index 2e0c3e7576..6ed7aa4a7c 100644 --- a/crates/engine/src/prefs/store.rs +++ b/crates/engine/src/prefs/store.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Map, Preferences, Serialize, Value}; // ------------------------------------------------------------------ store diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index af637f90d9..37d0fdf022 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -1,5 +1,10 @@ use super::shortcuts::normalize_shortcut; -use super::*; +use super::{ + AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview, + GpuBackend, Interpolation, LightTheme, LineStyle, LinuxDisplayServer, LogDestination, LogDetail, OtherCursor, PaintingCursor, PointSize, PressureCurve, + QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TextEngine, Theme, TypeUnit, UiFontSize, UiScale, + Unit, Value, +}; /// Preferences dialog sections in Photoshop's order: (id, title). pub const SECTIONS: [(&str, &str); 18] = [ diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index a065e48e6d..92ad78e5dd 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -254,7 +254,8 @@ pub fn encode_tip(t: &GrayTile) -> Vec { out.push(if eight { 8 } else { 16 }); out.extend_from_slice(&t.width.to_le_bytes()); out.extend_from_slice(&t.height.to_le_bytes()); - let mut z = flate2::write::ZlibEncoder::new(out, flate2::Compression::fast()); + // Level 3: zlib-rs level 1 writes static Huffman blocks only, 13-30% bigger than miniz's level 1. + let mut z = flate2::write::ZlibEncoder::new(out, flate2::Compression::new(3)); let mut buf = Vec::with_capacity(8192); let mut ok = true; for chunk in t.data.chunks(4096) { diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 4105ceb1c0..20cb95d95b 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -641,7 +641,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient(stops, angle, scale, style, reverse).in_mode(doc.mode); - let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill))); + let layer = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)); + let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) })?; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 18eb3dc80a..d1f360e573 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,6 +1,9 @@ use super::doc_state::DocState; use super::error::{EngineError, Result}; -use super::*; +use super::{ + Arc, Document, LayerId, LayerTarget, Session, Value, brush_cmds, channel_cmds, commands, hidden_target, jobs, layer_multi_cmds, retouch_cmds, smart_cmds, + type_cmds, +}; mod documents; mod execute; @@ -8,4 +11,4 @@ mod history; mod layers; pub use documents::move_item; -pub(crate) use history::{fix_selection, restore_target}; +pub(crate) use history::{carry_visibility, fix_selection, restore_target, visibility_changes}; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ea20944fbc..1ae07b35a8 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,5 +1,5 @@ use super::history::fix_selection; -use super::*; +use super::{DocState, Document, Session, channel_cmds, smart_cmds}; /// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when /// `i` is out of range. @@ -109,6 +109,7 @@ impl Session { let mut st = DocState::new(doc, path); st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; st.history.max_bytes = self.prefs.get().performance.history_budget_bytes(); + st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history; self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index c3822673bb..69c389f0c6 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,8 @@ -use super::history::fix_selection; -use super::*; +use super::history::{fix_selection, visibility_changes}; +use super::{ + Arc, Document, EngineError, LayerId, Result, Session, Value, brush_cmds, channel_cmds, commands, hidden_target, jobs, layer_multi_cmds, retouch_cmds, + smart_cmds, +}; impl Session { /// Run a command by id with JSON params. Returns a JSON result. @@ -69,6 +72,12 @@ impl Session { if id == "layer.new.layer" { crate::new_layer::parse(params)?; } + if id == "layer.new.group" { + crate::group_options::below(id, params)?; + } + if id == "layer.new.group" || id == "layer.new.groupFromLayers" { + crate::group_options::GroupOptions::parse(id, params)?; + } // A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the // document doesn't have. if spec.params.contains("\"layer\":id") @@ -163,6 +172,25 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + self.edit_with(label, true, f) + } + + /// Apply a change of layer visibility (and nothing else): a history state when History + /// Options › Make Layer Visibility Changes Undoable is on (the default, as in Photoshop), + /// otherwise the document changes without one, so the History panel and Undo skip it. + pub fn edit_visibility(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + let record = self.visibility_undoable(); + self.edit_with(label, record, f) + } + + /// History Options › Make Layer Visibility Changes Undoable. + pub fn visibility_undoable(&self) -> bool { + self.prefs().history_options.make_layer_visibility_changes_undoable + } + + /// [`Self::edit`], recording a history state only when `record` is set. An unrecorded change + /// still marks the document modified and leaves the redo states alone. + pub(crate) fn edit_with(&mut self, label: &str, record: bool, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { // Every mutation path (linked-file refresh, transform, place) re-renders smart objects // through this entry; the requesting session's authority must reach those renders. let _authorization = crate::jobs::render_authorization_scope(self.authorize); @@ -223,14 +251,20 @@ impl Session { let key = self.coalesce_request.clone(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let layers = st.layer_target(); - if key.is_none() || st.coalesce != key || !st.history.can_undo() { + if !record { + // Nothing to coalesce with: the next recorded step starts a state of its own. + st.coalesce = None; + } else if key.is_none() || st.coalesce != key || !st.history.can_undo() { st.history.set_current_layers(prior); - st.history.record(label, before, layers); + st.history.record(label, before.clone(), layers); + st.history.add_current_visibility(&visibility_changes(&before, &st.doc)); st.trim_history(); + st.coalesce = key; } else { st.history.set_current_layers(layers); + st.history.add_current_visibility(&visibility_changes(&before, &st.doc)); + st.coalesce = key; } - st.coalesce = key; st.bump_revision(keep_clean); st.last_damage = damage; self.samplers_removed += samplers_removed; diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index 0d619f47e9..b6e81e64bc 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -1,5 +1,5 @@ use super::documents::fix_active; -use super::*; +use super::{Arc, DocState, Document, LayerId, LayerTarget, Session, channel_cmds, layer_multi_cmds}; impl Session { pub fn undo(&mut self) -> bool { @@ -7,8 +7,11 @@ impl Session { if self.active_job().is_some() { return false; } + let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; + // The step being undone: the layers whose visibility it changed get it back. + let changed = st.history.current_visibility().to_vec(); match st.history.undo(st.doc.clone()) { // Undo Clear History: the list comes back, the document is the same one. Not an // edit: keep a clean document clean (the revision still moves for the panels). @@ -27,6 +30,9 @@ impl Session { if d.name != st.doc.name { Arc::make_mut(&mut d).name.clone_from(&st.doc.name); } + if keep_visibility { + carry_visibility(&st.doc, &mut d, &changed); + } // Pixels this step can have touched, so the canvas recomposites only that // (it recomposited everything before). let damage = layer_multi_cmds::step_damage(&d, &st.doc); @@ -45,6 +51,7 @@ impl Session { if self.active_job().is_some() { return false; } + let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; match st.history.redo(st.doc.clone()) { @@ -52,6 +59,11 @@ impl Session { if d.name != st.doc.name { Arc::make_mut(&mut d).name.clone_from(&st.doc.name); } + if keep_visibility { + // The step being redone sets the visibility it changed again. + let changed = st.history.current_visibility().to_vec(); + carry_visibility(&st.doc, &mut d, &changed); + } let damage = layer_multi_cmds::step_damage(&st.doc, &d); let before = std::mem::replace(&mut st.doc, d); let keep_clean = st.keeps_clean(&before); @@ -65,6 +77,79 @@ impl Session { } } +impl Session { + /// Take back the step a compound command just recorded, after its later part failed: the + /// document before the command is current again, nothing is left to redo, and in non-linear + /// history the later states that step moved stay where they were (an undo would land on the + /// last of them). Falls back to Undo plus forgetting the redo states when the history + /// changed since that step. Returns whether a step was taken back. + pub fn rollback(&mut self) -> bool { + if self.active_job().is_some() { + return false; + } + let keep_visibility = !self.visibility_undoable(); + let Some(st) = self.active_mut() else { return false }; + st.coalesce = None; + // The step taken back (and the later states it moved, in non-linear history): the layers + // whose visibility it changed get it back, the unrecorded changes stay, as for Undo. + let changed = st.history.current_visibility().to_vec(); + let Some((mut d, layers)) = st.history.rollback() else { + let undone = self.undo(); + if undone && let Some(st) = self.active_mut() { + st.history.clear_redo(); + } + return undone; + }; + if d.name != st.doc.name { + Arc::make_mut(&mut d).name.clone_from(&st.doc.name); + } + if keep_visibility { + carry_visibility(&st.doc, &mut d, &changed); + } + let damage = layer_multi_cmds::step_damage(&d, &st.doc); + let before = std::mem::replace(&mut st.doc, d); + // Taking back a selection-only step keeps a clean document clean, as Undo does (#6865). + let keep_clean = st.keeps_clean(&before); + restore_target(st, layers); + st.bump_revision(keep_clean); + st.last_damage = damage; + true + } +} + +/// History Options › Make Layer Visibility Changes Undoable off: showing or hiding a layer is not +/// a history state, so stepping through history must not bring the old visibility back either +/// (history states hold whole documents). Gives every layer of `to` that `from` also has the +/// visibility it has in `from`, except the `stepped` layers: a recorded step between the two +/// states changed their visibility (Apply Layer Comp, a snapshot, a visibility change recorded +/// while the option was on), so they keep the visibility `to` holds. Layers only one of them has +/// keep theirs. Copies `to` only when something differs. +pub(crate) fn carry_visibility(from: &Document, to: &mut Arc, stepped: &[LayerId]) { + let visible: std::collections::HashMap = from.walk().into_iter().map(|(_, _, l)| (l.id, l.visible)).collect(); + let changes: Vec<(LayerId, bool)> = to + .walk() + .into_iter() + .filter(|(_, _, l)| !stepped.contains(&l.id)) + .filter_map(|(_, _, l)| visible.get(&l.id).filter(|v| **v != l.visible).map(|v| (l.id, *v))) + .collect(); + if changes.is_empty() { + return; + } + let d = Arc::make_mut(to); + for (id, v) in changes { + if let Some(l) = d.layer_mut(id) { + l.visible = v; + } + } +} + +/// The layers (in both documents) whose visibility differs between `before` and `after`: what a +/// recorded step changed, for [`photocraft_ops::History::add_current_visibility`]. +pub(crate) fn visibility_changes(before: &Document, after: &Document) -> Vec { + let visible: std::collections::HashMap = before.walk().into_iter().map(|(_, _, l)| (l.id, l.visible)).collect(); + after.walk().into_iter().filter(|(_, _, l)| visible.get(&l.id).is_some_and(|v| *v != l.visible)).map(|(_, _, l)| l.id).collect() +} + /// Undo / redo: target the layers the restored state targeted, as far as they still exist. pub(crate) fn restore_target(st: &mut DocState, layers: LayerTarget) { // Deselect Layers is a valid history target, not a missing layer to replace with the top diff --git a/crates/engine/src/session/layers.rs b/crates/engine/src/session/layers.rs index b6e5d1c688..b8ea5ea89d 100644 --- a/crates/engine/src/session/layers.rs +++ b/crates/engine/src/session/layers.rs @@ -1,5 +1,5 @@ use super::history::fix_selection; -use super::*; +use super::{Arc, EngineError, LayerId, Result, Session, channel_cmds, type_cmds}; impl Session { /// Replace the active document's active layer without creating a history step. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 769fcefeb4..49b1797ff3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1185,7 +1185,8 @@ fn move_selected_layers_keeps_selected_group_children_and_supports_above_below() let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); let c = s.execute("layer.new.layer", json!({"name": "C"})).unwrap()["layer"].as_u64().unwrap(); let group = s.execute("layer.new.group", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); - // Made before B moves into G: a new group goes above the active layer, which would then be inside G. + // Made from C, not from G: like Photoshop a new group goes inside an open active group. + s.execute("layer.select", json!({"layer": c})).unwrap(); let target = s.execute("layer.new.group", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.moveTo", json!({"layer": b, "target": group, "position": "into"})).unwrap(); diff --git a/crates/engine/tests/it/layer_range_panel_order.rs b/crates/engine/tests/it/layer_range_panel_order.rs index d442d35198..86b4ad81c8 100644 --- a/crates/engine/tests/it/layer_range_panel_order.rs +++ b/crates/engine/tests/it/layer_range_panel_order.rs @@ -20,8 +20,10 @@ fn shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group() { select(&mut s, b, "replace"); select(&mut s, c, "add"); let group = s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap(); - s.execute("layer.setExpanded", json!({"layer": group, "expanded": true})).unwrap(); + // D goes above the closed group (an open one would take it inside, as in Photoshop). let d = s.execute("layer.new.layer", json!({"name": "D"})).unwrap()["layer"].as_u64().unwrap(); + // Group Layers makes a collapsed group, as in Photoshop; open it so B and C are visible rows. + s.execute("layer.setExpanded", json!({"layer": group, "expanded": true})).unwrap(); for (anchor, target, wanted) in [(d, group, vec![d, group]), (group, d, vec![d, group]), (group, b, vec![group, c, b]), (b, group, vec![group, c, b]), (c, b, vec![c, b])] diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index 91f42be306..c4d8359e18 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -13,6 +13,8 @@ fn fresh() -> Session { // Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer. photocraft_engine::print_cmds::disable_printing(); let mut s = Session::new(); + // `file.printers` and `file.print.printers` list no printers and do not ask the spooler. + s.printer_source = Some(std::sync::Arc::new(Vec::new)); s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s.execute("select.rect", json!({"x": 1, "y": 1, "width": 6, "height": 5})).unwrap(); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index c5ba725edc..7b379451dd 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -180,38 +180,52 @@ fn worker(rx: Receiver, bundle: PathBuf, sidecar: PathBuf, last: Arc.json` does not exist: a listing in between took the entry for a sidecar-less +/// bundle and listed it twice, or not at all (#9034). A rename swaps the name in one step, and +/// recovery data is private to the app, so there is no metadata to keep. fn write_sidecar(path: &Path, bytes: &[u8]) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - struct PrivateFs; - impl crate::atomic::AtomicFs for PrivateFs { - fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> { + struct SidecarFs; + impl crate::atomic::AtomicFs for SidecarFs { + // As before: Windows refuses a read-only sidecar up front, Unix replaces it. + #[cfg(not(unix))] + fn is_readonly(&self, path: &Path) -> bool { + crate::atomic::RealFs.is_readonly(path) + } + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(tmp)?; f.write_all(bytes)?; crate::atomic::sync_file(&f) } - fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> { - crate::atomic::RealFs.rename(from, to) - } - fn remove(&self, path: &Path) -> std::io::Result<()> { - crate::atomic::RealFs.remove(path) - } - fn sync_dir(&self, dir: &Path) -> std::io::Result<()> { - crate::atomic::RealFs.sync_dir(dir) - } - fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> { - crate::atomic::RealFs.copy_metadata(from, to) + #[cfg(not(unix))] + { + crate::atomic::RealFs.write_new(tmp, bytes) } } - crate::atomic::atomic_write_with(&PrivateFs, crate::atomic::RenameRetry::platform(), path, bytes)?; - Ok(()) - } - #[cfg(not(unix))] - { - crate::store::write_atomic(path, bytes) + fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.rename(from, to) + } + // `replace_existing` keeps the trait's default: this same one-step rename. + fn remove(&self, path: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.sync_dir(dir) + } + fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.copy_metadata(from, to) + } } + crate::atomic::atomic_write_with(&SidecarFs, crate::atomic::RenameRetry::platform(), path, bytes)?; + Ok(()) } fn read_sidecar_bounded(path: &Path, max: usize) -> Option> { diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 65f06fd8e2..a093bd10e0 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -11,7 +11,10 @@ use photocraft_doc::{ use photocraft_geom::{TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile, decode_pixel, encode_pixel}; -use crate::manifest::*; +use crate::manifest::{ + ChannelM, CompAppearanceM, CompStateM, ContentM, DocM, EffectsM, FillCacheM, Hash, LayerCompM, LayerM, MaskM, MetadataM, NamedPathM, PatternM, + SmartSourceM, SurfaceM, TileRef, +}; use crate::{FormatError, Result}; /// Deepest group nesting a bundle may hold — the one nesting limit every consumer of the layer @@ -125,9 +128,12 @@ fn video_m(v: &photocraft_doc::VideoData, sink: &mut dyn Sink) -> crate::manifes fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { let content = match &l.content { LayerContent::Raster(s) => ContentM::Raster { surface: surface_m(s, sink) }, - LayerContent::Group(g) => { - ContentM::Group { children: g.children.iter().map(|c| layer_m(c, sink)).collect(), expanded: g.expanded, artboard: g.artboard.clone() } - } + LayerContent::Group(g) => ContentM::Group { + children: g.children.iter().map(|c| layer_m(c, sink)).collect(), + expanded: g.expanded, + artboard: g.artboard.clone(), + frame_stroke: g.frame_stroke.clone(), + }, LayerContent::Adjustment(a) => ContentM::Adjustment { adjustment: a.clone() }, LayerContent::Fill(f) => ContentM::Fill { fill: f.clone() }, LayerContent::Text(t) => ContentM::Text { @@ -449,7 +455,9 @@ impl Loader<'_> { fn layer_with(&mut self, m: &LayerM, children: Vec) -> Result { let content = match &m.content { ContentM::Raster { surface } => LayerContent::Raster(self.surface(surface)?), - ContentM::Group { expanded, artboard, .. } => LayerContent::Group(Group { artboard: artboard.clone(), children, expanded: *expanded }), + ContentM::Group { expanded, artboard, frame_stroke, .. } => { + LayerContent::Group(Group { artboard: artboard.clone(), children, expanded: *expanded, frame_stroke: frame_stroke.clone() }) + } ContentM::Adjustment { adjustment } => LayerContent::Adjustment(adjustment.clone()), ContentM::Fill { fill } => LayerContent::Fill(fill.clone()), ContentM::Text { text, font_family, size_pt, color, transform, cache, psd_raw, runs, paragraphs, shape, orientation, antialias, warp } => { diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 72192c79f6..0b1e08b706 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -259,6 +259,9 @@ pub enum ContentM { /// Artboard (Layer › New › Artboard). #[serde(default)] artboard: Option, + /// A Photoshop Frame's stroke, drawn along the group's vector mask. + #[serde(default, skip_serializing_if = "Option::is_none")] + frame_stroke: Option, }, Adjustment { adjustment: Adjustment, diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index fb73819e32..97e285a573 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -64,6 +64,34 @@ fn png(img: &Rgba8Image) -> Result> { photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) } +fn previews(opts: &SaveOptions) -> Result)>> { + let (composite, thumbnail) = beside( + opts.thumbnail.is_some() && opts.composite.is_some(), + || opts.composite.as_ref().map(png).transpose(), + || opts.thumbnail.as_ref().map(png).transpose(), + ); + let mut out = Vec::new(); + if let Some(t) = thumbnail? { + out.push((THUMB, t)); + } + if let Some(t) = composite? { + out.push((COMPOSITE, t)); + } + Ok(out) +} + +fn beside(parallel: bool, a: impl FnOnce() -> Result + Send, b: impl FnOnce() -> B) -> (Result, B) { + if !parallel || cfg!(target_arch = "wasm32") { + let a = a(); + return (a, b()); + } + std::thread::scope(|sc| { + let h = sc.spawn(a); + let b = b(); + (h.join().unwrap_or_else(|_| Err(FormatError::Io(std::io::Error::other("preview worker panicked")))), b) + }) +} + // --------------------------------------------------------------------------- // Sources // --------------------------------------------------------------------------- @@ -481,7 +509,6 @@ struct FileSignature { struct Prepared { manifest: Vec, objects: BTreeMap, - previews: Vec<(&'static str, Vec)>, stats: SaveStats, } @@ -882,13 +909,6 @@ impl PcraftWriter { convert::check_nesting(&doc.layers)?; let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() }; let document = convert::doc_m(doc, &mut c); - let mut previews = Vec::new(); - if let Some(t) = &opts.thumbnail { - previews.push((THUMB, png(t)?)); - } - if let Some(t) = &opts.composite { - previews.push((COMPOSITE, png(t)?)); - } let manifest = Manifest { format_version: FORMAT_VERSION, generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), @@ -912,23 +932,40 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h), Object::Blob(b)); } - Ok(Prepared { manifest, objects, previews, stats }) + Ok(Prepared { manifest, objects, stats }) } /// Save as a ZIP bundle in memory. pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec, SaveStats)> { - let p = self.prepare(doc, opts)?; + // Another thread encodes the previews while the document is collected and its new + // objects are compressed on all cores (zstd dominates a full save). + let (previews, prepared) = beside( + opts.thumbnail.is_some() || opts.composite.is_some(), + || previews(opts), + || -> Result<_> { + let p = self.prepare(doc, opts)?; + let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect(); + let fresh: HashMap>> = par_compress(&todo)?.into_iter().map(|(path, data)| (path.clone(), data)).collect(); + Ok((p, fresh)) + }, + ); + let (p, mut fresh) = prepared?; + let previews = previews?; let mut stats = p.stats; - let mut z = ZipWriter::new(); - z.add(MANIFEST, &p.manifest)?; - for (name, data) in &p.previews { - z.add(name, data)?; - } let mut next = HashMap::with_capacity(p.objects.len()); let mut cached_bytes = 0usize; - // New objects are compressed on all cores first (zstd dominates a full save). - let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect(); - let mut fresh: HashMap<&String, Arc>> = par_compress(&todo)?.into_iter().collect(); + // The archive is sized for every entry up front (#7689): previews, then reused and fresh + // objects at their compressed sizes. + let entries = + std::iter::once((MANIFEST, p.manifest.len())).chain(previews.iter().map(|(name, data)| (*name, data.len()))).chain(p.objects.keys().map(|path| { + let size = self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |data| data.len()); + (path.as_str(), size) + })); + let mut z = ZipWriter::with_entries(entries)?; + z.add(MANIFEST, &p.manifest)?; + for (name, data) in &previews { + z.add(name, data)?; + } for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -976,6 +1013,7 @@ impl PcraftWriter { validate_bundle_file(dir, &dir.join(path))?; } let p = self.prepare(doc, opts)?; + let previews = previews(opts)?; let mut stats = p.stats; if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) { self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() }); @@ -1035,7 +1073,7 @@ impl PcraftWriter { } } } - for (name, data) in &p.previews { + for (name, data) in &previews { write_bundle_file(dir, &dir.join(name), data, fs)?; } // Pin all GC directories before removing stale previews or objects. @@ -1062,7 +1100,7 @@ impl PcraftWriter { // Omitted previews belong to the previous manifest until the new one commits. // After commit, a locked leftover is only housekeeping, just like unused objects. for stale in [THUMB, COMPOSITE] { - if !p.previews.iter().any(|(n, _)| *n == stale) { + if !previews.iter().any(|(n, _)| *n == stale) { let _ = remove_bundle_file(dir, &dir.join(stale), &gc); } } @@ -1190,6 +1228,53 @@ fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() { assert!(writer.compressed.is_empty()); } +#[cfg(test)] +#[test] +fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() { + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + let mut doc = Document::new("previews", Size::new(600, 3), photocraft_color::ColorMode::Rgb, SampleType::U8); + let mut surface = Surface::new(doc.pixel_format()); + for x in 0..600 { + surface.write_pixel(x, 1, &[x as f32 / 600.0, 0.5, 0.25, 1.0]); + } + doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface))); + let image = |w: u32, h: u32, k: u8| { + let mut i = Rgba8Image::new(w, h); + i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k)); + i + }; + let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13)); + for (t, c) in [(true, true), (true, false), (false, true), (false, false)] { + let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) }; + let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); + let zip = ZipReader::new(&bytes).unwrap(); + let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect(); + let mut want = vec![MANIFEST]; + want.extend(t.then_some(THUMB)); + want.extend(c.then_some(COMPOSITE)); + assert_eq!(names[..want.len()], want[..]); + assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}"); + for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] { + assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}"); + } + } + let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) }; + assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); + let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) }; + assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); + + let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) }; + PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap(); + assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap()); + assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[cfg(all(test, unix))] #[test] fn gc_pinned_directory_handles_resist_subdirectory_swap() { diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 5057e52168..2452f87903 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -3,6 +3,7 @@ //! unzipped and re-zipped by another tool still loads). No ZIP64, no //! encryption, no multi-disk archives. +use std::borrow::Cow; use std::collections::HashMap; use std::io::Read; @@ -31,18 +32,41 @@ impl Default for ZipWriter { } impl ZipWriter { + /// Creates an empty archive of STORE entries. pub fn new() -> Self { ZipWriter { out: Vec::new(), central: Vec::new(), count: 0 } } + /// Reserves the complete archive from its entry names and payload sizes. + pub(crate) fn with_entries<'a>(entries: impl IntoIterator) -> Result { + let (size, directory) = entries.into_iter().try_fold((22usize, 0usize), |(size, directory), (name, bytes)| { + let directory = directory.checked_add(46 + name.len()); + let size = size.checked_add(76).and_then(|n| n.checked_add(name.len())).and_then(|n| n.checked_add(name.len())).and_then(|n| n.checked_add(bytes)); + size.zip(directory).ok_or_else(|| FormatError::Unsupported("zip size overflow".into())) + })?; + let mut writer = Self::new(); + writer.out.try_reserve_exact(size).map_err(|_| FormatError::LimitExceeded("not enough memory for zip".into()))?; + writer.central.try_reserve_exact(directory).map_err(|_| FormatError::LimitExceeded("not enough memory for zip directory".into()))?; + Ok(writer) + } + + /// Adds a STORE entry, rejecting names or data that exceed ZIP field limits. + /// The entry path has a 16-bit byte length, independent of filesystem limits. pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), FormatError> { if self.count == u16::MAX { return Err(FormatError::Unsupported("more than 65535 zip entries (ZIP64 not supported)".into())); } let size = u32::try_from(data.len()).map_err(|_| FormatError::Unsupported("zip entry over 4 GiB".into()))?; let offset = u32::try_from(self.out.len()).map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; - let crc = crc32(data); let name_b = name.as_bytes(); + let name_len = u16::try_from(name_b.len()).map_err(|_| FormatError::Unsupported("zip entry path exceeds the 65535-byte format limit".into()))?; + let local_len = 30usize + .checked_add(name_b.len()) + .and_then(|n| n.checked_add(data.len())) + .ok_or_else(|| FormatError::Unsupported("zip entry size overflow".into()))?; + self.out.try_reserve(local_len).map_err(|_| FormatError::LimitExceeded("not enough memory for zip entry".into()))?; + self.central.try_reserve(46 + name_b.len()).map_err(|_| FormatError::LimitExceeded("not enough memory for zip directory".into()))?; + let crc = crc32(data); // Local file header. let o = &mut self.out; o.extend_from_slice(&LOCAL_SIG.to_le_bytes()); @@ -54,7 +78,7 @@ impl ZipWriter { o.extend_from_slice(&crc.to_le_bytes()); o.extend_from_slice(&size.to_le_bytes()); o.extend_from_slice(&size.to_le_bytes()); - o.extend_from_slice(&(name_b.len() as u16).to_le_bytes()); + o.extend_from_slice(&name_len.to_le_bytes()); o.extend_from_slice(&0u16.to_le_bytes()); o.extend_from_slice(name_b); o.extend_from_slice(data); @@ -70,7 +94,7 @@ impl ZipWriter { c.extend_from_slice(&crc.to_le_bytes()); c.extend_from_slice(&size.to_le_bytes()); c.extend_from_slice(&size.to_le_bytes()); - c.extend_from_slice(&(name_b.len() as u16).to_le_bytes()); + c.extend_from_slice(&name_len.to_le_bytes()); c.extend_from_slice(&[0; 8]); // extra len, comment len, disk, internal attrs c.extend_from_slice(&0u32.to_le_bytes()); // external attrs c.extend_from_slice(&offset.to_le_bytes()); @@ -79,9 +103,11 @@ impl ZipWriter { Ok(()) } + /// Appends the central directory and returns the complete archive. pub fn finish(mut self) -> Result, FormatError> { let cd_offset = u32::try_from(self.out.len()).map_err(|_| FormatError::Unsupported("zip over 4 GiB".into()))?; - let cd_size = self.central.len() as u32; + let cd_size = u32::try_from(self.central.len()).map_err(|_| FormatError::Unsupported("zip directory over 4 GiB".into()))?; + self.out.try_reserve(self.central.len().saturating_add(22)).map_err(|_| FormatError::LimitExceeded("not enough memory to finish zip".into()))?; self.out.extend_from_slice(&self.central); self.out.extend_from_slice(&EOCD_SIG.to_le_bytes()); self.out.extend_from_slice(&[0; 4]); // disk numbers @@ -112,7 +138,7 @@ pub struct ZipReader<'a> { pub entries: Vec, /// Name -> index of the first entry with that name, so `find` is O(1) /// instead of a scan per lookup (a bundle reads one entry per tile). - by_name: HashMap, + by_name: HashMap, usize>, } fn u16_at(b: &[u8], o: usize) -> Result { @@ -123,6 +149,7 @@ fn u32_at(b: &[u8], o: usize) -> Result { } impl<'a> ZipReader<'a> { + /// Checks ZIP record bounds and indexes the first entry for each name. pub fn new(bytes: &'a [u8]) -> Result { if bytes.len() < 22 { return Err(FormatError::corrupt("not a zip archive (too short)")); @@ -150,7 +177,8 @@ impl<'a> ZipReader<'a> { let cd_size = u32_at(bytes, eocd + 12)? as usize; let cd_off = u32_at(bytes, eocd + 16)? as usize; let cd_end = cd_off.checked_add(cd_size).filter(|end| *end <= eocd).ok_or_else(|| FormatError::corrupt("zip central directory out of range"))?; - let mut entries = Vec::with_capacity(count.min(bytes.len() / 46)); + let mut entries = Vec::with_capacity(count.min(cd_size / 46)); + let mut by_name = HashMap::with_capacity(entries.capacity()); let mut p = cd_off; for _ in 0..count { let header_end = p.checked_add(46).filter(|end| *end <= cd_end).ok_or_else(|| FormatError::corrupt("truncated zip central directory entry"))?; @@ -168,7 +196,7 @@ impl<'a> ZipReader<'a> { let local = u32_at(bytes, p + 42)? as usize; let name_end = header_end.checked_add(nlen).filter(|end| *end <= cd_end).ok_or_else(|| FormatError::corrupt("zip entry name out of range"))?; let name = bytes.get(header_end..name_end).ok_or_else(|| FormatError::corrupt("truncated zip name"))?; - let name = String::from_utf8_lossy(name).into_owned(); + let name = String::from_utf8_lossy(name); if flags & 1 != 0 { return Err(FormatError::Unsupported("encrypted zip entries".into())); } @@ -194,14 +222,11 @@ impl<'a> ZipReader<'a> { if data_offset.checked_add(compressed).is_none_or(|end| end > bytes.len()) { return Err(FormatError::corrupt(format!("zip entry `{name}` out of range"))); } - entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset }); + // The index borrows valid UTF-8 names; duplicate names keep the first entry. + by_name.entry(name.clone()).or_insert(entries.len()); + entries.push(Entry { name: name.into_owned(), method, crc, compressed, uncompressed, data_offset }); p = entry_end; } - let mut by_name = HashMap::with_capacity(entries.len()); - for (i, e) in entries.iter().enumerate() { - // Duplicate names: the first entry wins, as with a front-to-back scan. - by_name.entry(e.name.clone()).or_insert(i); - } Ok(ZipReader { bytes, entries, by_name }) } @@ -227,8 +252,9 @@ impl<'a> ZipReader<'a> { // small forged archive must not reserve gigabytes up front, so the // preallocation is capped (DEFLATE expands at most ~1032x). let mut out = Vec::with_capacity(e.uncompressed.min(raw.len().saturating_mul(1032)).min(64 << 20)); - flate2::read::DeflateDecoder::new(raw) - .take((max as u64).saturating_add(1)) + // One surplus byte proves a false size without expanding to the caller's larger budget. + flate2::bufread::DeflateDecoder::new(raw) + .take((e.uncompressed as u64).saturating_add(1)) .read_to_end(&mut out) .map_err(|err| FormatError::corrupt(format!("zip entry `{}`: {err}", e.name)))?; out @@ -261,6 +287,20 @@ mod tests { assert_eq!(crc32(b"123456789"), 0xCBF4_3926); } + /// ZIP entry paths have a format limit even when each component is short. + #[test] + fn entry_path_length_is_checked_before_writing() { + let mut writer = ZipWriter::new(); + for name in ["dir/".repeat(16384), "é/".repeat(21846)] { + assert!(matches!(writer.add(&name, b"invalid"), Err(FormatError::Unsupported(_)))); + assert!(writer.out.is_empty() && writer.central.is_empty()); + } + let name = "dir/".repeat(16383) + "end"; + writer.add(&name, b"valid").unwrap(); + let bytes = writer.finish().unwrap(); + assert_eq!(ZipReader::new(&bytes).unwrap().read_by_name(&name, 5).unwrap(), b"valid"); + } + #[test] fn roundtrip() { let mut w = ZipWriter::new(); @@ -274,6 +314,7 @@ mod tests { assert_eq!(r.read_by_name("dir/b.bin", 100).unwrap(), [0, 1, 2, 3]); assert!(r.read_by_name("empty", 0).unwrap().is_empty()); assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); + assert!(r.by_name.keys().all(|name| matches!(name, Cow::Borrowed(_)))); } fn archive_with_comment(comment: &[u8]) -> Vec { @@ -379,6 +420,46 @@ mod tests { assert_eq!(r.read_by_name("b", 100).unwrap(), b"other"); } + /// Reservation changes neither entry bytes nor final output, and avoids growth. + #[test] + fn whole_archive_reservation_preserves_output() { + let entries = [("层/é.png", &b"image"[..]), ("empty", &b""[..]), ("empty", &b"duplicate"[..])]; + for entries in [&entries[..0], &entries[..]] { + let mut previous = ZipWriter::new(); + let mut reserved = ZipWriter::with_entries(entries.iter().map(|(name, data)| (*name, data.len()))).unwrap(); + let (capacity, directory) = (reserved.out.capacity(), reserved.central.capacity()); + for (name, data) in entries { + previous.add(name, data).unwrap(); + reserved.add(name, data).unwrap(); + } + assert_eq!(reserved.central.capacity(), directory); + let bytes = reserved.finish().unwrap(); + assert_eq!(bytes.capacity(), capacity); + assert_eq!(bytes, previous.finish().unwrap()); + } + assert!(matches!(ZipWriter::with_entries([("x", usize::MAX)]), Err(FormatError::Unsupported(_)))); + assert!(matches!(ZipWriter::with_entries([("x", isize::MAX as usize)]), Err(FormatError::LimitExceeded(_)))); + } + + /// Invalid UTF-8 names retain their lossy spelling and first-entry lookup. + #[test] + fn name_index_borrows_unicode_and_owns_lossy_names() { + let mut writer = ZipWriter::new(); + for name in ["层/é.png", "a", "b"] { + writer.add(name, name.as_bytes()).unwrap(); + } + let mut bytes = writer.finish().unwrap(); + let mut p = u32_at(&bytes, bytes.len() - 6).unwrap() as usize; + p += 46 + "层/é.png".len(); + bytes[p + 46] = 0xff; + bytes[p + 47 + 46] = 0xfe; + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("层/é.png", 100).unwrap(), "层/é.png".as_bytes()); + assert!(matches!(reader.by_name.get_key_value("层/é.png").unwrap().0, Cow::Borrowed(_))); + assert!(matches!(reader.by_name.get_key_value("�").unwrap().0, Cow::Owned(_))); + assert_eq!(reader.read_by_name("�", 1).unwrap(), b"a"); + } + #[test] fn crc_mismatch_detected() { let mut w = ZipWriter::new(); @@ -390,9 +471,63 @@ mod tests { assert!(r.read_by_name("a", 100).is_err()); } - /// A valid DEFLATE-compressed bundle may be read with an effectively - /// unlimited caller budget (e.g. corruption/round-trip checks). On 64-bit - /// targets `usize::MAX as u64 + 1` overflows, so the decoder must saturate. + #[test] + fn slice_deflate_matches_buffered_reader_and_preserves_checks() { + use std::io::Write as _; + + let content = b"PhotoCraft ZIP payload".repeat(5000); + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::none()); + encoder.write_all(&content).unwrap(); + let compressed = encoder.finish().unwrap(); + assert!(compressed.len() > 32 * 1024); + let mut previous = Vec::new(); + flate2::read::DeflateDecoder::new(compressed.as_slice()).read_to_end(&mut previous).unwrap(); + let mut writer = ZipWriter::new(); + writer.add("payload", &compressed).unwrap(); + let bytes = writer.finish().unwrap(); + let reader = ZipReader::new(&bytes).unwrap(); + let mut entry = reader.entries[0].clone(); + entry.method = 8; + entry.uncompressed = content.len(); + entry.crc = crc32(&content); + assert_eq!(reader.read(&entry, content.len()).unwrap(), previous); + assert!(matches!(reader.read(&entry, content.len() - 1), Err(FormatError::LimitExceeded(_)))); + entry.crc ^= 1; + assert!(matches!(reader.read(&entry, content.len()), Err(FormatError::Corrupt(_)))); + entry.crc ^= 1; + entry.compressed /= 2; + assert!(matches!(reader.read(&entry, content.len()), Err(FormatError::Corrupt(_)))); + } + + /// A false small size must stop expansion before decoding the rest of the stream. + #[test] + fn deflate_surplus_stops_before_a_corrupt_suffix() { + // Exceed miniz's internal 32 KiB output buffer before the corrupt block. + let raw = [&[0, 0xff, 0xff, 0, 0][..], &[b'x'; 65535], &[7]].concat(); + let reader = ZipReader { bytes: &raw, entries: Vec::new(), by_name: HashMap::new() }; + for declared in [0, 7] { + let entry = Entry { name: "surplus".into(), method: 8, crc: 0, compressed: raw.len(), uncompressed: declared, data_offset: 0 }; + let err = reader.read(&entry, usize::MAX).unwrap_err(); + assert!(err.to_string().contains("wrong size"), "{err}"); + } + } + + /// Correct output length and CRC do not replace the final DEFLATE block. + #[test] + fn deflate_requires_a_complete_stream_including_empty_entries() { + for (raw, content) in [(&[0, 1, 0, 0xfe, 0xff, b'x', 1, 0, 0, 0xff, 0xff][..], &b"x"[..]), (&[3, 0][..], &b""[..])] { + let reader = ZipReader { bytes: raw, entries: Vec::new(), by_name: HashMap::new() }; + let mut entry = + Entry { name: "complete".into(), method: 8, crc: crc32(content), compressed: raw.len(), uncompressed: content.len(), data_offset: 0 }; + assert_eq!(reader.read(&entry, content.len()).unwrap(), content); + for cut in 0..raw.len() { + entry.compressed = cut; + assert!(matches!(reader.read(&entry, content.len()), Err(FormatError::Corrupt(_))), "cut at {cut}"); + } + } + } + + /// An unlimited caller budget still checks a DEFLATE entry's declared size. #[test] fn deflated_entry_with_unbounded_limit_round_trips() { use std::io::Write as _; diff --git a/crates/format/tests/it/autosave.rs b/crates/format/tests/it/autosave.rs index 24d61d50d3..26be1c9d76 100644 --- a/crates/format/tests/it/autosave.rs +++ b/crates/format/tests/it/autosave.rs @@ -304,3 +304,54 @@ fn failed_cleanup_after_commit_does_not_fail_the_save() { assert_eq!(load_path(&dir).unwrap(), doc); std::fs::remove_dir_all(dir.parent().unwrap()).unwrap(); } + +/// #9034: while a document's autosave replaces its entry, a listing (another launch's recovery +/// prompt, or the launch's own check after closing a second document) sees that entry exactly +/// once. On Windows the sidecar used to be replaced through `ReplaceFileW`, which moves the old +/// sidecar to a backup name before moving the new one in: a listing in between found the bundle +/// without a sidecar and listed it a second time (or, the other way round, not at all). +#[test] +fn a_listing_during_an_autosave_sees_the_entry_exactly_once() { + let dir = temp_dir("listing-during-autosave"); + let doc = Document::new("Busy", photocraft_doc::Size { width: 4, height: 4 }, ColorMode::Rgb, SampleType::U8); + save_once(&dir, "busy", doc.clone()); + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let writer = { + let (dir, done) = (dir.clone(), done.clone()); + std::thread::spawn(move || { + // Sets `done` however the thread ends, so a failing writer stops the listing loop + // and `join` reports its panic instead of the test hanging. + struct SetOnDrop(Arc); + impl Drop for SetOnDrop { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + let _done = SetOnDrop(done); + let saver = Autosaver::new(&dir, "busy"); + for revision in 2..=200 { + saver.request(Arc::new(doc.clone()), revision, None, SaveOptions::default()); + // Wait for this write, so every revision replaces the sidecar once. + let completed = loop { + let completed = saver.take_completed(); + if !completed.is_empty() { + break completed; + } + std::thread::yield_now(); + }; + assert!(completed.iter().all(|(_, result)| result.is_ok()), "revision {revision}: {completed:?}"); + } + saver.flush() + }) + }; + let mut listings = 0u32; + while !done.load(std::sync::atomic::Ordering::SeqCst) { + let entries = list_recovery(&dir); + let keys: Vec<&str> = entries.iter().map(|e| e.info.key.as_str()).collect(); + assert_eq!(keys, ["busy"], "listing {listings} during an autosave: {entries:?}"); + listings += 1; + } + assert!(writer.join().unwrap().is_some_and(|r| r.is_ok())); + assert!(listings > 0); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/format/tests/it/frame_stroke.rs b/crates/format/tests/it/frame_stroke.rs new file mode 100644 index 0000000000..89567ee7f0 --- /dev/null +++ b/crates/format/tests/it/frame_stroke.rs @@ -0,0 +1,37 @@ +//! A Photoshop Frame's stroke (`Group::frame_stroke`) survives a `.pcraft` round trip; older +//! manifests, written before it existed, load without one (#4381). + +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Fill, Layer, LayerContent, ShapeStroke, Size, StrokeAlign}; +use photocraft_format::*; + +fn stroke_of(d: &Document) -> Option { + match &d.layers.first()?.content { + LayerContent::Group(g) => g.frame_stroke.clone(), + _ => None, + } +} + +#[test] +fn frame_stroke_roundtrips_and_defaults_when_absent() { + let mut doc = Document::new("a", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + let stroke = + ShapeStroke { width: 8.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), dashes: vec![2.0, 1.0], ..ShapeStroke::default() }; + let mut g = Layer::group("Frame 1", vec![]); + if let LayerContent::Group(gr) = &mut g.content { + gr.frame_stroke = Some(stroke.clone()); + } + doc.layers.push(g); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(stroke_of(&load_from_bytes(&bytes).unwrap()), Some(stroke)); + + // A manifest without the key (older files) loads with no stroke. + let m = read_manifest(&bytes).unwrap(); + let mut v = serde_json::to_value(&m.document).unwrap(); + let content = &mut v["layers"][0]["content"]; + assert!(content.get("frame_stroke").is_some()); + content.as_object_mut().unwrap().remove("frame_stroke"); + let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); + let photocraft_format::manifest::ContentM::Group { frame_stroke, .. } = &old.layers[0].content else { panic!("not a group") }; + assert!(frame_stroke.is_none()); +} diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index 6c822f4282..71eae66ddd 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -6,6 +6,7 @@ mod common; mod corruption; mod directory_symlinks; mod float_roundtrip; +mod frame_stroke; mod layer_labels; mod roundtrip; mod smart_contents; diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index 06d3928fd1..32ef503139 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -280,10 +280,11 @@ fn text_run_baseline_roundtrips() { fn zip_incremental_only_new_tiles() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); let mut w = PcraftWriter::new(); - let (_, s1) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); + let (first, s1) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert!(s1.tiles_total > 5); assert_eq!(s1.tiles_written, s1.tiles_total); - let (_, s2) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); + let (cached, s2) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(first, cached); assert_eq!(s2.tiles_written, 0); assert_eq!(s2.tiles_reused, s2.tiles_total); assert_eq!(s2.blobs_written, 0); @@ -293,6 +294,7 @@ fn zip_incremental_only_new_tiles() { let (bytes, s3) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert_eq!(s3.tiles_written, 1); assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + assert_eq!(bytes, PcraftWriter::new().save_zip(&doc, &SaveOptions::default()).unwrap().0); } #[test] @@ -884,6 +886,25 @@ fn variables_follow_layer_id_remapping() { assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); } +#[test] +fn color_lookup_lut_file_round_trips_and_old_files_load() { + use photocraft_doc::Adjustment; + use photocraft_doc::adjust::{LutFormat, LutSource}; + let lut = Some(std::sync::Arc::new(vec![0.5f32; 24])); + let source = Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"0 1023\n0 0 0\n".to_vec()) }); + let adj = Adjustment::ColorLookup { name: "Film.3dl".into(), lut, size: 2, tetrahedral: false, dither: false, source }; + let mut doc = Document::new("d", photocraft_doc::Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + doc.insert_above(None, Layer::new("lookup", LayerContent::Adjustment(adj.clone()))); + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + assert_eq!(back, doc, "the LUT file survives a .pcraft round trip (#6544)"); + // A .pcraft written before the field existed has no `source`: it loads as None. + let mut v = serde_json::to_value(&adj).unwrap(); + let removed = v.as_object_mut().and_then(|o| o.values_mut().next()).and_then(|f| f.as_object_mut()).and_then(|f| f.remove("source")); + assert!(removed.is_some(), "{v}"); + let Adjustment::ColorLookup { source: None, name, .. } = serde_json::from_value(v).unwrap() else { panic!("old lookup must load") }; + assert_eq!(name, "Film.3dl"); +} + /// Removing previews is post-commit housekeeping, not part of publishing the new document. #[test] fn directory_save_without_previews_removes_stale_files_after_commit() { diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 90c70cc30f..8c1320cf6a 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -836,15 +836,8 @@ impl BezierMesh { *f = f.max((1.0 - interior) * w); } // Every control point moves with the anchor it is closest to. - for j in 0..self.ny() { - for i in 0..nx { - let (ai, aj) = (((i + 1) / 3).min(cols), ((j + 1) / 3).min(rows)); - let k = share[aj * (cols + 1) + ai]; - let p = &mut self.points[j * nx + i]; - p[0] += d[0] * k; - p[1] += d[1] * k; - } - } + let ny = self.ny(); + move_control_points(&mut self.points, nx, ny, cols, rows, &share, d); // Close the gap exactly, with three groups of control points that share the work: // - the four inner points of the grabbed patch (all of it in the middle of the patch, // little near a grid line, where they barely reach the surface); @@ -1022,6 +1015,18 @@ impl BezierMesh { } } +fn move_control_points(points: &mut [[f64; 2]], nx: usize, ny: usize, cols: usize, rows: usize, shares: &[f64], delta: [f64; 2]) { + for j in 0..ny { + for i in 0..nx { + let (ai, aj) = (((i + 1) / 3).min(cols), ((j + 1) / 3).min(rows)); + let share = shares[aj * (cols + 1) + ai]; + let point = &mut points[j * nx + i]; + point[0] += delta[0] * share; + point[1] += delta[1] * share; + } + } +} + /// A complete warp: what Photoshop's `warp` descriptor stores, plus the custom mesh. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/gpu/src/batch.rs b/crates/gpu/src/batch.rs index 988a580cc4..700ed8361f 100644 --- a/crates/gpu/src/batch.rs +++ b/crates/gpu/src/batch.rs @@ -1,6 +1,6 @@ use super::convert::{W, words}; use super::textures::Resident; -use super::*; +use super::{DeviceHealth, F, I, Kernel, Plan, Rect, TILE_SIZE, TileCoord, U, plan, timing}; /// The uniform record of a pass. pub(crate) fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { diff --git a/crates/gpu/src/cells.rs b/crates/gpu/src/cells.rs index 8d8a44ec97..ab3c451bed 100644 --- a/crates/gpu/src/cells.rs +++ b/crates/gpu/src/cells.rs @@ -4,7 +4,7 @@ use super::convert::{convert_tile, convert_tiles, keep_overlap, put_tiles, words use super::kit::{Bound, FrameRes, texture_group, uniform_group}; use super::paging::cell_rect; use super::textures::{CachedPattern, Cell, ChunkOut, ResKey, Resident, ResidentRef, Stats, Tex, TexKind}; -use super::*; +use super::{Arc, Compositor, DocId, HashMap, I, Kernel, Pattern, Plan, Rect, Role, Surface, TILE_SIZE, TileCoord, plan}; impl Compositor { /// Bring the pages of `cell` that `plan` samples up to date and resolve its effect maps. diff --git a/crates/gpu/src/convert.rs b/crates/gpu/src/convert.rs index 6b0a7e6f98..9dbfc81814 100644 --- a/crates/gpu/src/convert.rs +++ b/crates/gpu/src/convert.rs @@ -1,6 +1,6 @@ use super::batch::write_tile; use super::textures::{Resident, TexKind}; -use super::*; +use super::{Arc, F, HashMap, I, PixelFormat, Rect, SampleType, Surface, TILE_SIZE, Tile, TileCoord, U, fx}; /// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles /// of a 16-bit page are 32 MiB). diff --git a/crates/gpu/src/encode.rs b/crates/gpu/src/encode.rs index 037a4cc80d..cfa4bbd723 100644 --- a/crates/gpu/src/encode.rs +++ b/crates/gpu/src/encode.rs @@ -4,7 +4,7 @@ use super::convert::lut_texture; use super::kit::FrameRes; use super::paging::{OVER_BUDGET, cell_rect, first_error, grid_rect_count, grid_rects, lru_victims, scoped_work, uniform_buffer_len}; use super::textures::{ChunkOut, FxKey, ResKey, Resident, Stats}; -use super::*; +use super::{Compositor, Document, LayerId, Plan, Rect, Surface, Unsupported, plan, timing}; impl Compositor { /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 34982cd2be..ceff607686 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -25,13 +25,14 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; -use photocraft_compose::effects::BevelPaint; use photocraft_compose::effects::FieldKind; -use photocraft_doc::{BevelTechnique, Contour, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent, Pattern}; +use photocraft_doc::{Contour, Effect, GlobalLight, Layer, LayerContent, Pattern}; use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::{Surface, Tile}; -use crate::plan::{Kernel, stroke_widths}; +use crate::plan::Kernel; + +mod builders; /// Input of a map stage. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -306,181 +307,14 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, patterns: &[Pattern], anchor: (f64, f64)) -> MapProgram { let mut b = B { stages: Vec::new(), fields: Vec::new() }; let maps = match e { - Effect::DropShadow(s) | Effect::InnerShadow(s) => { - // shadow_map - let inner = matches!(e, Effect::InnerShadow(_)); - let angle = if s.use_global_light { light.angle } else { s.angle }; - let (dx, dy) = offset(angle, s.distance); - let src = b.shift(In::Shape, dx, dy, if inner { 1.0 } else { 0.0 }, inner); - let (r, bw) = photocraft_compose::effects::spread_split(s.size, s.spread); - let mut m = src; - if r > 0.0 { - // dist_outside of the shifted map is the shifted field (integer offsets; shifted-in - // pixels are far away for a drop shadow, inside for an inner shadow). - let f = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r); - let d = if dx == 0.0 && dy == 0.0 { - f - } else { - b.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) - }; - m = b.dilate(src, d, r); - } - let m = b.blur(m, bw); - b.finish(m, None, false, &s.contour, inner, 0); - 1 - } - Effect::OuterGlow(g) | Effect::InnerGlow(g) => { - // glow_map - let inner = matches!(e, Effect::InnerGlow(_)); - let center = inner && g.source == GlowSource::Center; - match g.technique { - GlowTechnique::Precise => { - let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size); - let solid = g.size * g.spread; - let soft = (g.size + 1.0 - solid).max(1e-3); - let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0)); - b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center); - } - GlowTechnique::Softer => { - // Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha. - let src = if inner { b.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; - let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread); - let mut m = src; - if r > 0.0 { - let d = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r); - m = b.dilate(src, d, r); - } - let m = b.blur(m, bw); - b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center); - } - } - 1 - } - Effect::Satin(s) => { - // satin_map - let (dx, dy) = offset(s.angle, photocraft_compose::effects::satin_distance(s)); - let a = b.shift(In::Shape, dx, dy, 0.0, false); - let a = b.conv(a, photocraft_compose::effects::tent_kernel(s.size)); - let c = b.shift(In::Shape, -dx, -dy, 0.0, false); - let c = b.conv(c, photocraft_compose::effects::tent_kernel(s.size)); - b.finish_lut(a, Some(c), s.invert, photocraft_compose::effects::satin_lut(&s.contour), true, 0); - 1 - } - Effect::BevelEmboss(bv) => { - // bevel_maps: height map (tent blur of the shape, or chiselled distance ramps), then - // highlight / shadow shading inside and / or outside the shape. - let g = photocraft_compose::effects::bevel_geom(bv); - let size = bv.size.max(1.0); - let paint = |p: BevelPaint| match p { - BevelPaint::Outer => 0.0, - BevelPaint::Both => 1.0, - BevelPaint::Inner => 2.0, - }; - let mut h = if bv.technique == BevelTechnique::Smooth { - b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) - } else { - let [din, dout] = b.bevel_fields(size); - let soft = g.chisel_soft > 0.0; - let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0)); - // As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp. - if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h } - }; - if let Some(c) = &bv.contour { - // Contour element: the height through the contour (`bevel_contour_lut`). - let lut = photocraft_compose::effects::bevel_contour_lut(c); - let mut st = stage(Kernel::MFinish, Some(h), None, [0.0, 0.0, 1.0, 0.0], 0); - st.lut = Some(Arc::new(lut)); - h = b.push(st); - } - if let Some(t) = &bv.texture - && let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| p.is_paintable()) - { - // Texture element (`effects::bevel_height`): luminance × depth / unit added. - let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) }; - let pl = photocraft_compose::pattern::Placement::anchored(anchor, t.link, t.phase, t.scale, 0.0); - let (origin, cs, inv) = pl.parts(); - let mut st = stage(Kernel::MBevelTex, Some(h), None, [t.depth / unit, f32::from(u8::from(t.invert)), 0.0, 0.0], 0); - st.p3 = [origin.0 as f32, origin.1 as f32, cs.0 as f32, cs.1 as f32]; - st.p4 = [inv as f32, pat.width as f32, pat.height as f32, 0.0]; - st.pattern = Some(pat.clone()); - h = b.push(st); - } - if bv.soften >= 1.0 { - h = b.conv(h, photocraft_compose::effects::tent_kernel(bv.soften)); - } - let (angle, altitude) = if bv.use_global_light { (light.angle, light.altitude) } else { (bv.angle, bv.altitude) }; - let (sa, ca) = angle.to_radians().sin_cos(); - let (se, ce) = altitude.to_radians().sin_cos(); - let lut = contour_lut(&bv.gloss_contour).map(Arc::new); - // Region (`fs_mbevelshade`): 0 inside, 1 under the edge and outside, 2 / 3 emboss / - // pillow emboss (both halves in one map). - let passes: Vec<(f32, f32)> = match g.paint { - BevelPaint::Inner => vec![(g.depth, 0.0)], - BevelPaint::Outer => vec![(g.depth, 1.0)], - BevelPaint::Both => vec![(g.depth, if g.pillow { 3.0 } else { 2.0 })], - }; - let mut out = 0; - for (depth, region) in &passes { - for which in [0.0, 1.0] { - let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1); - s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))]; - s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0]; - s.lut = lut.clone(); - s.s = Some(In::Shape); - s.out = Some(out); - out += 1; - b.stages.push(s); - } - } - out - } - Effect::Stroke(st) => { - // composite_with_effects' stroke bands (maps 0 outside, 1 inside) - let (in_w, out_w) = stroke_widths(st); - if out_w > 0.0 { - // Narrow strokes keep the fitted chamfer, wide ones the exact transform (#3646); - // the CPU picks the same field by the same width (`stroke_outside_exact`). - let kind = match (vector_shape, photocraft_compose::effects::stroke_outside_exact(out_w)) { - (true, true) => FieldKind::StrokeOutsideVector, - (true, false) => FieldKind::StrokeOutsideVectorChamfer, - (false, true) => FieldKind::StrokeOutsideExact, - (false, false) => FieldKind::StrokeOutside, - }; - let d = b.field(kind, out_w); - let mut s = stage(Kernel::MStroke, Some(d), None, [out_w, 0.0, 0.0, 0.0], 0); - s.out = Some(0); - b.stages.push(s); - } - if in_w > 0.0 { - let d = b.field(FieldKind::StrokeInside, in_w); - let mut s = stage(Kernel::MStroke, Some(d), None, [in_w, 0.0, 0.0, 0.0], 0); - s.out = Some(1); - b.stages.push(s); - } - 2 - } + Effect::DropShadow(s) | Effect::InnerShadow(s) => b.shadow(s, light, matches!(e, Effect::InnerShadow(_))), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => b.glow(g, matches!(e, Effect::InnerGlow(_))), + Effect::Satin(s) => b.satin(s), + Effect::BevelEmboss(bv) => b.bevel(bv, light, patterns, anchor), + Effect::Stroke(st) => b.stroke(st, vector_shape), Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. } => return MapProgram::empty(), }; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for s in &b.stages { - (format!("{:?}", s.kernel), s.a, s.b, s.s, s.radius, s.out).hash(&mut h); - for v in s.p0.iter().chain(&s.p1).chain(&s.p3).chain(&s.p4) { - v.to_bits().hash(&mut h); - } - if let Some(p) = &s.pattern { - (&p.id, &p.name, p.width, p.height).hash(&mut h); - for (c, t) in p.surface.tiles() { - (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h); - } - } - if let Some(l) = &s.lut { - for v in l.iter() { - v.to_bits().hash(&mut h); - } - } - } - (maps, &b.fields).hash(&mut h); - MapProgram { key: h.finish() | 1, maps, stages: b.stages, fields: b.fields } + MapProgram { key: b.key(maps), maps, stages: b.stages, fields: b.fields } } // --------------------------------------------------------------------------------------------- diff --git a/crates/gpu/src/fx/builders.rs b/crates/gpu/src/fx/builders.rs new file mode 100644 index 0000000000..39bc9c4fba --- /dev/null +++ b/crates/gpu/src/fx/builders.rs @@ -0,0 +1,208 @@ +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use photocraft_compose::effects::{self as fx, BevelGeom, BevelPaint, FieldKind}; +use photocraft_doc::{Bevel, BevelTechnique, BevelTexture, GlobalLight, Glow, GlowSource, GlowTechnique, Pattern, Satin, Shadow, StrokeFx}; + +use super::{B, In, contour_lut, offset, stage}; +use crate::plan::{Kernel, stroke_widths}; + +/// A flag as the shaders read it: 1.0 or 0.0. +fn flag(v: bool) -> f32 { + f32::from(u8::from(v)) +} + +impl B { + /// Drop or Inner Shadow stages. Returns the number of maps written. + pub(super) fn shadow(&mut self, s: &Shadow, light: &GlobalLight, inner: bool) -> usize { + // shadow_map + let angle = if s.use_global_light { light.angle } else { s.angle }; + let (dx, dy) = offset(angle, s.distance); + let src = self.shift(In::Shape, dx, dy, if inner { 1.0 } else { 0.0 }, inner); + let m = self.spread_blur(src, (s.size, s.spread), inner, dx, dy); + self.finish(m, None, false, &s.contour, inner, 0); + 1 + } + + /// `src` spread or choked by `spread` of `size`, then blurred by the rest. + fn spread_blur(&mut self, src: In, (size, spread): (f32, f32), inner: bool, dx: f32, dy: f32) -> In { + let (r, bw) = fx::spread_split(size, spread); + if r <= 0.0 { + return self.blur(src, bw); + } + // The shifted map's distance field is the field shifted by the same whole pixels. + let f = self.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r); + let d = if dx == 0.0 && dy == 0.0 { + f + } else { + self.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) + }; + let m = self.dilate(src, d, r); + self.blur(m, bw) + } + + /// Outer or Inner Glow stages, Precise or Softer. Returns the number of maps written. + pub(super) fn glow(&mut self, g: &Glow, inner: bool) -> usize { + // glow_map + let center = inner && g.source == GlowSource::Center; + let m = match g.technique { + GlowTechnique::Precise => { + let d = self.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size); + let solid = g.size * g.spread; + let soft = (g.size + 1.0 - solid).max(1e-3); + self.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0)) + } + GlowTechnique::Softer => { + // Inner glows spread 1 - alpha. + let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape }; + self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0) + } + }; + self.finish_glow(m, fx::glow_lut(g), inner, center); + 1 + } + + /// Satin stages. Returns the number of maps written. + pub(super) fn satin(&mut self, s: &Satin) -> usize { + // satin_map + let (dx, dy) = offset(s.angle, fx::satin_distance(s)); + let a = self.shift(In::Shape, dx, dy, 0.0, false); + let a = self.conv(a, fx::tent_kernel(s.size)); + let c = self.shift(In::Shape, -dx, -dy, 0.0, false); + let c = self.conv(c, fx::tent_kernel(s.size)); + self.finish_lut(a, Some(c), s.invert, fx::satin_lut(&s.contour), true, 0); + 1 + } + + /// Bevel and Emboss stages: the height map with its contour, texture and soften, then the shading. Returns the number of maps written. + pub(super) fn bevel(&mut self, bv: &Bevel, light: &GlobalLight, patterns: &[Pattern], anchor: (f64, f64)) -> usize { + // bevel_maps: a height map, then highlight and shadow shading. + let g = fx::bevel_geom(bv); + let mut h = self.bevel_height(bv, &g); + if let Some(c) = &bv.contour { + // Contour element: the height through `bevel_contour_lut`. + let mut st = stage(Kernel::MFinish, Some(h), None, [0.0, 0.0, 1.0, 0.0], 0); + st.lut = Some(Arc::new(fx::bevel_contour_lut(c))); + h = self.push(st); + } + if let Some(t) = &bv.texture + && let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| p.is_paintable()) + { + let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) }; + h = self.bevel_texture(h, t, pat, unit, anchor); + } + if bv.soften >= 1.0 { + h = self.conv(h, fx::tent_kernel(bv.soften)); + } + self.bevel_shading(h, bv, &g, light) + } + + /// The bevel's height map: a tent blur of the shape for Smooth, distance ramps for the chisel techniques. + fn bevel_height(&mut self, bv: &Bevel, g: &BevelGeom) -> In { + if bv.technique == BevelTechnique::Smooth { + return self.conv(In::Shape, fx::tent_kernel(g.width)); + } + let paint = match g.paint { + BevelPaint::Outer => 0.0, + BevelPaint::Both => 1.0, + BevelPaint::Inner => 2.0, + }; + let size = bv.size.max(1.0); + let [din, dout] = self.bevel_fields(size); + let soft = g.chisel_soft > 0.0; + let h = self.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint, size, flag(soft), 0.0], 0)); + // Only emboss styles blur a Chisel Soft ramp, as in `effects::bevel_height`. + if soft && g.paint == BevelPaint::Both { self.conv(h, fx::tent_kernel(g.chisel_soft)) } else { h } + } + + /// `h` with the Texture element's pattern luminance added. + fn bevel_texture(&mut self, h: In, t: &BevelTexture, pat: &Pattern, unit: f32, anchor: (f64, f64)) -> In { + // Texture element: adds luminance × depth / unit to the height. + let pl = photocraft_compose::pattern::Placement::anchored(anchor, t.link, t.phase, t.scale, 0.0); + let (origin, cs, inv) = pl.parts(); + let mut st = stage(Kernel::MBevelTex, Some(h), None, [t.depth / unit, flag(t.invert), 0.0, 0.0], 0); + st.p3 = [origin.0 as f32, origin.1 as f32, cs.0 as f32, cs.1 as f32]; + st.p4 = [inv as f32, pat.width as f32, pat.height as f32, 0.0]; + st.pattern = Some(pat.clone()); + self.push(st) + } + + /// Highlight and shadow shading of height map `h` into maps 0 and 1. Returns 2. + fn bevel_shading(&mut self, h: In, bv: &Bevel, g: &BevelGeom, light: &GlobalLight) -> usize { + let (angle, altitude) = if bv.use_global_light { (light.angle, light.altitude) } else { (bv.angle, bv.altitude) }; + let (sa, ca) = angle.to_radians().sin_cos(); + let (se, ce) = altitude.to_radians().sin_cos(); + let lut = contour_lut(&bv.gloss_contour).map(Arc::new); + // Region for `fs_mbevelshade`: 0 inside, 1 outside, 2 emboss, 3 pillow emboss. + let region = match g.paint { + BevelPaint::Inner => 0.0, + BevelPaint::Outer => 1.0, + BevelPaint::Both if g.pillow => 3.0, + BevelPaint::Both => 2.0, + }; + let outer_chisel = g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth; + for (out, which) in [0.0, 1.0].into_iter().enumerate() { + let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1); + s.p1 = [g.depth, region, which, flag(lut.is_some())]; + s.p3 = [flag(outer_chisel), 0.0, 0.0, 0.0]; + s.lut = lut.clone(); + s.s = Some(In::Shape); + s.out = Some(out); + self.stages.push(s); + } + 2 + } + + /// Stroke stages, the outside band into map 0 and the inside band into map 1. Returns 2. + pub(super) fn stroke(&mut self, st: &StrokeFx, vector_shape: bool) -> usize { + // Stroke bands: map 0 outside, map 1 inside. + let (in_w, out_w) = stroke_widths(st); + if out_w > 0.0 { + // Narrow strokes keep the fitted chamfer, wide ones the exact transform (#3646); + // the CPU picks the same field by the same width (`stroke_outside_exact`). + let kind = match (vector_shape, fx::stroke_outside_exact(out_w)) { + (true, true) => FieldKind::StrokeOutsideVector, + (true, false) => FieldKind::StrokeOutsideVectorChamfer, + (false, true) => FieldKind::StrokeOutsideExact, + (false, false) => FieldKind::StrokeOutside, + }; + self.stroke_band(kind, out_w, 0); + } + if in_w > 0.0 { + self.stroke_band(FieldKind::StrokeInside, in_w, 1); + } + 2 + } + + /// One stroke band of `width` from distance field `kind` into map `out`. + fn stroke_band(&mut self, kind: FieldKind, width: f32, out: usize) { + let d = self.field(kind, width); + let mut s = stage(Kernel::MStroke, Some(d), None, [width, 0.0, 0.0, 0.0], 0); + s.out = Some(out); + self.stages.push(s); + } + + /// A hash of the built stages and fields that keys the compiled program (never 0). + pub(super) fn key(&self, maps: usize) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &self.stages { + (format!("{:?}", s.kernel), s.a, s.b, s.s, s.radius, s.out).hash(&mut h); + for v in s.p0.iter().chain(&s.p1).chain(&s.p3).chain(&s.p4) { + v.to_bits().hash(&mut h); + } + if let Some(p) = &s.pattern { + (&p.id, &p.name, p.width, p.height).hash(&mut h); + for (c, t) in p.surface.tiles() { + (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h); + } + } + if let Some(l) = &s.lut { + for v in l.iter() { + v.to_bits().hash(&mut h); + } + } + } + (maps, &self.fields).hash(&mut h); + h.finish() | 1 + } +} diff --git a/crates/gpu/src/fx_passes.rs b/crates/gpu/src/fx_passes.rs index a549979f63..f3f5ce6da3 100644 --- a/crates/gpu/src/fx_passes.rs +++ b/crates/gpu/src/fx_passes.rs @@ -3,7 +3,7 @@ use super::consts::{MAP16, MAP32}; use super::kit::MapDraw; use super::paging::field_reaches; use super::textures::{FxEntry, FxKey, ProgState, Stats, Tex}; -use super::*; +use super::{Arc, Compositor, Document, FieldKind, HashMap, LayerContent, Rect, Unsupported, fx, plan}; impl Compositor { /// Build or update the effect maps of one layer over `region`: its whole effect region, or diff --git a/crates/gpu/src/kit.rs b/crates/gpu/src/kit.rs index 2de048a2e2..6ac1b76dc3 100644 --- a/crates/gpu/src/kit.rs +++ b/crates/gpu/src/kit.rs @@ -1,7 +1,7 @@ use super::consts::{CHUNK_UNIFORM, OP_UNIFORM, STRIDE}; use super::convert::{lut_texture, words}; use super::textures::{ResKey, ResidentRef}; -use super::*; +use super::{Arc, HashMap, I, Kernel, NonZeroU64, Rect}; /// Pipelines and layouts, shared by cheap clones (wgpu handles are reference counted). #[derive(Clone)] diff --git a/crates/gpu/src/paging.rs b/crates/gpu/src/paging.rs index c6d4b61563..b07f8e8db4 100644 --- a/crates/gpu/src/paging.rs +++ b/crates/gpu/src/paging.rs @@ -1,6 +1,6 @@ use super::consts::STRIDE; use super::textures::Cell; -use super::*; +use super::{DeviceHealth, FieldKind, HashMap, Rect, Unsupported, fx}; /// Document pixels of page cell `c`. pub(crate) fn cell_rect(c: Cell, page: u32) -> Rect { diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index 42dd17e099..a38cdde867 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -1,5 +1,5 @@ use super::types::Unsupported; -use super::*; +use super::{Adjustment, BlendMode, Fill, Gradient, Layer, LayerContent, Pattern, StrokePosition, ToneSpace, Transfer, adjust, lut3d_len}; /// Paint source of an effect. pub(super) enum Paint<'a> { diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs index 577237e975..88de5a81b4 100644 --- a/crates/gpu/src/plan/planner.rs +++ b/crates/gpu/src/plan/planner.rs @@ -2,7 +2,7 @@ use super::lower::{ Paint, adjustment_on_gpu, adjustment_program, check_gradient_stops, gradient_rows, placement, single_content_pass, stroke_widths, style_index, }; use super::types::{Checkpoint, DocCtx, FxLayer, Kernel, MapRef, MaskUse, Pass, Role, SurfaceRef, TexUse, Unsupported}; -use super::*; +use super::{Adjustment, BlendMode, Effect, Fill, FxPaint, Glow, Layer, LayerContent, LayerId, Plan, Rect, Slot, Surface, bounds, has_effects}; pub(super) struct Planner<'a> { passes: Vec>, @@ -29,6 +29,27 @@ enum Cov { One, } +struct EffectState<'a> { + layer: &'a Layer, + items: Vec<&'a Effect>, + rev: Vec<(usize, &'a Effect)>, + fx: usize, + content: Slot, + lay_src: Slot, + vstroke: Option, + backdrop: Slot, + w: Slot, + l: Slot, + clip: Rect, + sb: Rect, + anchor: (f64, f64), + outline: bool, + relative: bool, + vector_shape: bool, + atop: bool, + premerge: bool, +} + /// Shader flags for effect passes (keep in sync with compose.wgsl). Coverage `m` of a paint, with /// `a` the layer's alpha and `inside` = `a > 0.5/255`: /// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`; @@ -133,6 +154,28 @@ pub const INIT_OUTLINE: i32 = 7; /// `MapRef::item` of the layer's effect shape (`compose::layer_shape`) instead of an effect map. pub const SHAPE_MAP: usize = usize::MAX; +fn check_effect_gradients(items: &[&Effect]) -> Result<(), Unsupported> { + for effect in items { + let gradient = match effect { + Effect::GradientOverlay { gradient, .. } => Some(gradient), + Effect::Stroke(stroke) => match &stroke.paint { + FxPaint::Gradient(g) => Some(g), + _ => None, + }, + Effect::OuterGlow(glow) | Effect::InnerGlow(glow) => match &glow.paint { + FxPaint::Gradient(g) => Some(g), + _ => None, + }, + _ => None, + }; + if let Some(gradient) = gradient { + check_gradient_stops(&gradient.stops)?; + check_gradient_stops(&gradient.opacity_stops)?; + } + } + Ok(()) +} + impl<'a> Planner<'a> { pub(super) fn new(cx: DocCtx<'a>) -> Self { Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new(), visits: 0 } @@ -374,6 +417,15 @@ impl<'a> Planner<'a> { p.mask = self.mask_use(layer); after = self.emit(p); } + // A Frame's stroke over the frame's content, at the group's opacity (compose). + if let Some(stroke) = photocraft_compose::shape_split::frame_stroke(layer, self.cx.canvas, self.cx.depth) { + let stroke = self.shape_part(layer, Role::Stroke, stroke); + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(after); + p.b = Some(stroke); + p.opacity = opacity; + after = self.emit(p); + } if visible_clipped.is_empty() { self.release(before); return Ok(after); @@ -554,12 +606,20 @@ impl<'a> Planner<'a> { match &layer.content { LayerContent::Group(g) => { let empty = self.clear(); - let s = self.stack(&g.children, empty)?; + let mut s = self.stack(&g.children, empty)?; if layer.mask.is_some() || layer.vector_mask.is_some() { let mut p = Pass::new(Kernel::Mask, 0); p.a = Some(s); p.mask = self.mask_use(layer); - return Ok(self.emit(p)); + s = self.emit(p); + } + // A Frame's stroke over its masked content (compose::render_content). + if let Some(stroke) = photocraft_compose::shape_split::frame_stroke(layer, self.cx.canvas, self.cx.depth) { + let stroke = self.shape_part(layer, Role::Stroke, stroke); + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(s); + p.b = Some(stroke); + s = self.emit(p); } Ok(s) } @@ -707,10 +767,11 @@ impl<'a> Planner<'a> { Ok(self.emit(p)) } - /// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop` - /// (atop = true: effects over the base treated as opaque, keeping the base's alpha). + /// Plans enabled layer effects in the same phase order as the CPU compositor. /// Consumes `backdrop`. fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result { + let items: Vec<&'a Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); + check_effect_gradients(&items)?; let canvas = self.cx.canvas; let region = bounds::effect_region(layer, canvas); let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas)); @@ -775,44 +836,67 @@ impl<'a> Planner<'a> { content = self.emit(p); } - let items: Vec<&'a Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); - for e in &items { - let gradient = match e { - Effect::GradientOverlay { gradient, .. } => Some(gradient), - Effect::Stroke(s) => match &s.paint { - FxPaint::Gradient(g) => Some(g), - _ => None, - }, - Effect::OuterGlow(g) | Effect::InnerGlow(g) => match &g.paint { - FxPaint::Gradient(g) => Some(g), - _ => None, - }, - _ => None, - }; - if let Some(g) = gradient { - check_gradient_stops(&g.stops)?; - check_gradient_stops(&g.opacity_stops)?; - } - } // Linked patterns tile from the effects reference point (else the layer's top-left). + let premerge = photocraft_compose::effects::premerges_layer(layer); let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))); let vector_shape = matches!(layer.content, LayerContent::Shape(_)) && !outline; - let map = |item: usize, map: usize| MapRef { fx, item, map }; - let rev: Vec<(usize, &'a Effect)> = items.iter().copied().enumerate().rev().collect(); - let init = |s: &mut Self, src: Slot, b: Option, kind: i32, opacity: f32| { - let mut p = Pass::new(Kernel::FxInit, 0); - p.a = Some(src); - p.b = b; - p.adjust_kind = kind; - p.opacity = opacity; - p.clip = Some(clip); - s.emit(p) + let rev = items.iter().copied().enumerate().rev().collect(); + let mut work = EffectState { + layer, + items, + rev, + fx, + content, + lay_src, + vstroke, + backdrop, + w: backdrop, + l: lay_src, + clip, + sb, + anchor, + outline, + relative, + vector_shape, + atop, + premerge, }; + if let Some(merged) = self.effect_exterior(&mut work) { + self.release(work.content); + return Ok(merged); + } + self.effect_layer_base(&mut work); + self.effect_paints(&mut work); + self.effect_vector_stroke(&mut work); + self.effect_inside_strokes(&mut work); + self.effect_inner_bevel(&mut work); + self.effect_alpha(&mut work); + self.effect_outside_strokes(&mut work); + self.effect_outer_bevel(&mut work); + let merged = self.effect_merge(&mut work); + self.release(work.content); + Ok(self.write_back(work.backdrop, merged, work.clip)) + } + fn effect_exterior(&mut self, work: &mut EffectState<'a>) -> Option { + let layer = work.layer; + let items = work.items.clone(); + let rev = work.rev.clone(); + let fx = work.fx; + let content = work.content; + let lay_src = work.lay_src; + let backdrop = work.backdrop; + let clip = work.clip; + let sb = work.sb; + let atop = work.atop; + let relative = work.relative; + let premerge = work.premerge; + let anchor = work.anchor; + let map = |item: usize, map: usize| MapRef { fx, item, map }; // Exterior effects over the backdrop (an opaque copy of it for clipped layers). let mut w = if atop { let b = self.retain(backdrop); - init(self, b, None, INIT_OPAQUE, 1.0) + self.fx_init(clip, b, None, INIT_OPAQUE, 1.0) } else { self.retain(backdrop) }; @@ -849,7 +933,6 @@ impl<'a> Planner<'a> { // Only exterior effects (drop shadows, outer glows): nothing is painted between the // layer's colour inside its shape and the shape's alpha, so the merge derives both from // the shape (`F_LAYER_SHAPE`) instead of two passes building the layer. - let premerge = photocraft_compose::effects::premerges_layer(layer); if !relative && !premerge && items.iter().all(|e| matches!(e, Effect::DropShadow(_) | Effect::OuterGlow(_))) { let mut p = Pass::new(Kernel::FxMerge, 0); p.a = Some(w); @@ -862,16 +945,28 @@ impl<'a> Planner<'a> { p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = Some(clip); let merged = self.emit(p); - self.release(content); - return Ok(self.write_back(backdrop, merged, clip)); + let merged = self.write_back(backdrop, merged, clip); + return Some(merged); } + work.w = w; + None + } + + fn effect_layer_base(&mut self, work: &mut EffectState<'a>) { + let layer = work.layer; + let content = work.content; + let lay_src = work.lay_src; + let w = work.w; + let clip = work.clip; + let relative = work.relative; + let premerge = work.premerge; // The layer: its colour at fill opacity inside its shape; interior effects are painted // relative to the shape (coverage within it), then the shape's alpha applies. let mut l = if relative { let c = self.retain(content); - init(self, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity) + self.fx_init(clip, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity) } else { - init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity) + self.fx_init(clip, lay_src, None, INIT_INSIDE, layer.fill_opacity) }; // Blend Interior Effects as Group off: the layer merges in its mode first and the // interior effects paint over the result (`effects::premerges_layer`). @@ -885,6 +980,18 @@ impl<'a> Planner<'a> { p.clip = Some(clip); l = self.emit(p); } + work.l = l; + } + + fn effect_paints(&mut self, work: &mut EffectState<'a>) { + let rev = work.rev.clone(); + let fx = work.fx; + let content = work.content; + let mut l = work.l; + let clip = work.clip; + let sb = work.sb; + let anchor = work.anchor; + let map = |item: usize, map: usize| MapRef { fx, item, map }; for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) @@ -919,6 +1026,15 @@ impl<'a> Planner<'a> { l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb); } } + work.l = l; + } + + fn effect_vector_stroke(&mut self, work: &mut EffectState<'a>) { + let layer = work.layer; + let content = work.content; + let mut l = work.l; + let vstroke = work.vstroke; + let clip = work.clip; if let Some(s) = vstroke { let mut p = Pass::new(Kernel::FxInit, 0); p.a = Some(l); @@ -929,6 +1045,19 @@ impl<'a> Planner<'a> { p.clip = Some(clip); l = self.emit(p); } + work.l = l; + } + + fn effect_inside_strokes(&mut self, work: &mut EffectState<'a>) { + let layer = work.layer; + let rev = work.rev.clone(); + let fx = work.fx; + let content = work.content; + let mut l = work.l; + let clip = work.clip; + let sb = work.sb; + let anchor = work.anchor; + let map = |item: usize, map: usize| MapRef { fx, item, map }; // Inside stroke parts are painted over the layer (bottom instance first). for &(i, e) in &rev { if let Effect::Stroke(st) = e { @@ -949,6 +1078,17 @@ impl<'a> Planner<'a> { } } } + work.l = l; + } + + fn effect_inner_bevel(&mut self, work: &mut EffectState<'a>) { + let rev = work.rev.clone(); + let fx = work.fx; + let content = work.content; + let mut l = work.l; + let clip = work.clip; + let sb = work.sb; + let map = |item: usize, map: usize| MapRef { fx, item, map }; let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint; for &(i, e) in &rev { if let Effect::BevelEmboss(b) = e @@ -968,9 +1108,32 @@ impl<'a> Planner<'a> { l = self.paint(l, content, Cov::Map(map(i, 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, F_REL, clip, sb); } } + work.l = l; + } + + fn effect_alpha(&mut self, work: &mut EffectState<'a>) { + let content = work.content; + let mut l = work.l; + let clip = work.clip; // The shape's own alpha. let c = self.retain(content); - l = init(self, l, Some(c), INIT_ALPHA, 1.0); + l = self.fx_init(clip, l, Some(c), INIT_ALPHA, 1.0); + work.l = l; + } + + fn effect_outside_strokes(&mut self, work: &mut EffectState<'a>) { + let layer = work.layer; + let items = work.items.clone(); + let fx = work.fx; + let content = work.content; + let mut w = work.w; + let l = work.l; + let clip = work.clip; + let sb = work.sb; + let anchor = work.anchor; + let outline = work.outline; + let vector_shape = work.vector_shape; + let map = |item: usize, map: usize| MapRef { fx, item, map }; // Outside stroke parts lie beneath the layer and blend onto the exterior result with their // own mode; a higher stroke covers the ones below it (each takes its coverage × opacity // not yet taken above it, blended over the result as it was before the strokes). @@ -987,7 +1150,7 @@ impl<'a> Planner<'a> { // The layer's alpha rides along the coverage: `effects::outline_share`, and for // pixel layers the stroke's share beneath a partly covering layer (fill < 100 %). let ll = self.retain(l); - cover = Some(init(self, ll, None, INIT_COVER, 1.0)); + cover = Some(self.fx_init(clip, ll, None, INIT_COVER, 1.0)); } for (n, &(i, st)) in outs.iter().enumerate() { let paint = self.fx_paint(&st.paint, anchor); @@ -1029,6 +1192,18 @@ impl<'a> Planner<'a> { p.clip = Some(clip); w = self.emit(p); } + work.w = w; + } + + fn effect_outer_bevel(&mut self, work: &mut EffectState<'a>) { + let rev = work.rev.clone(); + let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint; + let fx = work.fx; + let content = work.content; + let mut w = work.w; + let clip = work.clip; + let sb = work.sb; + let map = |item: usize, map: usize| MapRef { fx, item, map }; for &(i, e) in &rev { if let Effect::BevelEmboss(b) = e && bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer @@ -1049,13 +1224,30 @@ impl<'a> Planner<'a> { } } + work.w = w; + } + + fn effect_merge(&mut self, work: &mut EffectState<'a>) -> Slot { + let layer = work.layer; + let rev = work.rev.clone(); + let fx = work.fx; + let content = work.content; + let w = work.w; + let l = work.l; + let backdrop = work.backdrop; + let clip = work.clip; + let sb = work.sb; + let atop = work.atop; + let premerge = work.premerge; + let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint; + let map = |item: usize, map: usize| MapRef { fx, item, map }; let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev .iter() .filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None }) .filter(|(_, b)| bevel_paint(b) == photocraft_compose::effects::BevelPaint::Both) .collect(); - let merged = if late.is_empty() { + if late.is_empty() { let mut p = Pass::new(Kernel::FxMerge, 0); p.a = Some(w); p.b = Some(l); @@ -1093,11 +1285,17 @@ impl<'a> Planner<'a> { p.flags = if atop { F_ATOP } else { 0 } | F_NO_LAYER; p.clip = Some(clip); self.emit(p) - }; - self.release(content); + } + } - // Write the clipped result back into the backdrop (in place when nothing else holds it). - Ok(self.write_back(backdrop, merged, clip)) + fn fx_init(&mut self, clip: Rect, src: Slot, b: Option, kind: i32, opacity: f32) -> Slot { + let mut pass = Pass::new(Kernel::FxInit, 0); + pass.a = Some(src); + pass.b = b; + pass.adjust_kind = kind; + pass.opacity = opacity; + pass.clip = Some(clip); + self.emit(pass) } fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> { diff --git a/crates/gpu/src/plan/tests.rs b/crates/gpu/src/plan/tests.rs index 283fe034de..83f286bf2b 100644 --- a/crates/gpu/src/plan/tests.rs +++ b/crates/gpu/src/plan/tests.rs @@ -108,7 +108,8 @@ fn outer_glow_plan_knocks_out_see_through_fill() { #[test] fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() { for size in [1 << 22, u32::MAX] { - let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; + let a = + Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false, source: None }; let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8, photocraft_color::ColorMode::Rgb); assert_eq!((kind, rows.is_none()), (0, true), "size {size}"); } diff --git a/crates/gpu/src/plan/types.rs b/crates/gpu/src/plan/types.rs index e83fac8c8c..13005dbba2 100644 --- a/crates/gpu/src/plan/types.rs +++ b/crates/gpu/src/plan/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BlendMode, Document, GlobalLight, Layer, LayerId, Pattern, Rect, Slot, Surface, Transfer}; /// Pipeline (fragment entry point) of a pass. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index 14dd73a831..9abf5f071e 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -1,5 +1,5 @@ use super::textures::Stats; -use super::*; +use super::{Compositor, Document, Rect, Unsupported}; /// Blocking readback of a composite (native tests and tools). #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index 06f7a6ce81..1eef2147b5 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -3,7 +3,9 @@ use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, M use super::kit::{Kit, SHADER, tex_entry, uniform_entry}; use super::paging::{cell_rect, field_reaches, first_error, grid_rects}; use super::textures::{FxEntry, Resident, Tex, TexKind}; -use super::*; +use super::{ + Compositor, DeviceHealth, DocId, Document, Fault, HashMap, Kernel, LayerContent, Plan, Rect, Role, TILE_SIZE, Unsupported, fx, plan, prefix, timing, +}; impl Compositor { /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). @@ -386,7 +388,7 @@ impl Compositor { /// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on /// the CPU compositor. - fn check_pattern(&self, pat: &Pattern) -> Result<(), Unsupported> { + fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> { if pat.width > self.device_max || pat.height > self.device_max { return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); } diff --git a/crates/gpu/src/textures.rs b/crates/gpu/src/textures.rs index 1b28f61150..5b52dfefaa 100644 --- a/crates/gpu/src/textures.rs +++ b/crates/gpu/src/textures.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocId, FieldKind, HashMap, LayerId, PixelFormat, Rect, Role, SampleType, Tile, TileCoord, fx, timing}; /// A finished chunk: straight-alpha RGBA32F pixels of `rect` (document coordinates) at the /// texture's origin. diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index d56b0eddae..ea1af9951e 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -715,7 +715,7 @@ fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment { } } } - Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither } + Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither, source: None } } /// 32-bit Hue/Saturation on colours at and above HSL lightness 1 and with a negative channel, @@ -2482,6 +2482,36 @@ fn stroked_shapes_with_clipped_layers() { } } +/// A Photoshop Frame's stroke (#4381): along the group's vector mask, above its masked content, +/// for an empty frame and one holding a layer, pass-through and isolated. +#[test] +fn frame_strokes() { + let Some(mut g) = gpu() else { return }; + use photocraft_doc::vector::{Path, ShapeStroke, StrokeAlign, Subpath, VectorMask}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (blend, align, content) in [ + (BlendMode::PassThrough, StrokeAlign::Inside, false), + (BlendMode::PassThrough, StrokeAlign::Outside, true), + (BlendMode::Multiply, StrokeAlign::Center, true), + ] { + let mut d = Document::new("t", Size::new(80, 64), ColorMode::Rgb, depth); + d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3)); + let kids = if content { vec![noise_layer("c", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 7, 0.4)] } else { vec![] }; + let mut l = Layer::group("Frame 1", kids); + l.blend = blend; + l.opacity = 0.8; + l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]))); + if let LayerContent::Group(gr) = &mut l.content { + gr.frame_stroke = Some(ShapeStroke { width: 5.0, align, paint: Fill::Solid(Color::rgb(0.9, 0.1, 0.1)), ..Default::default() }); + } + d.layers.push(l); + let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()); + assert!(st.is_ok(), "planned on the GPU"); + check(&mut g, &d, &format!("{depth:?} frame stroke {blend:?} {align:?} content {content}")); + } + } +} + #[test] fn artboards() { let Some(mut g) = gpu() else { return }; @@ -3076,6 +3106,25 @@ fn pass_through_clipping_base_with_an_adjustment() { check(&mut g, &d, "pass-through clipping base with an adjustment"); } +/// Transparency Shapes Layer off changes the special eight's blend (the layer's alpha blends as +/// Fill, #5692); the GPU has no pass for it and leaves such documents to the CPU. Other modes look +/// the same either way and stay on the GPU. +#[test] +fn transparency_shapes_off_special_blend_falls_back_to_the_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 2, 0.0); + l.blend = BlendMode::ColorDodge; + l.opacity = 0.53; + l.advanced.transparency_shapes = false; + d.layers.push(l); + assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "Color Dodge with Transparency Shapes Layer off"); + if let Some(l) = d.layers.last_mut() { + l.blend = BlendMode::Multiply; + } + check(&mut g, &d, "Multiply with Transparency Shapes Layer off"); +} + /// #5846: a shape's Density and Feather soften its fill (its own rendered pixels), with and /// without a vector stroke over clipped layers and layer effects: the GPU matches the CPU. #[test] diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 83ccd3b784..4eb91fb728 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -49,6 +49,7 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } [dev-dependencies] +miniz_oxide = "0.9" crc32fast = "1.5" photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } diff --git a/crates/io/examples/bench_abr.rs b/crates/io/examples/bench_abr.rs index 0811cb0ae8..17d4740962 100644 --- a/crates/io/examples/bench_abr.rs +++ b/crates/io/examples/bench_abr.rs @@ -62,7 +62,7 @@ fn main() { // `--write `: also save a small showcase file (procedural tips) for UI snapshots. let args: Vec = std::env::args().collect(); if let Some(path) = args.iter().position(|a| a == "--write").and_then(|i| args.get(i + 1)) { - use photocraft_paint::procedural::*; + use photocraft_paint::procedural::{charcoal_tip, leaf_tip, sponge_tip, star_tip}; let tips = [("Leafy", leaf_tip(96)), ("Charcoal Stick", charcoal_tip(96, 3)), ("Sea Sponge", sponge_tip(96, 5)), ("Star Burst", star_tip(96))]; let samples: Vec = tips .iter() diff --git a/crates/io/examples/bench_export.rs b/crates/io/examples/bench_export.rs new file mode 100644 index 0000000000..bd5685ecc7 --- /dev/null +++ b/crates/io/examples/bench_export.rs @@ -0,0 +1,93 @@ +//! Export timings on a realistic photo document (default 7360×4912 = 36 MP): the opened photo, +//! a painted layer covering part of the canvas, and a soft half-transparent layer. +//! +//! ```sh +//! cargo run --release -p photocraft-io --example bench_export -- [--size 7360x4912] [--reps 7] [--only pcraft] [--out DIR] +//! ``` +//! +//! Each row is the median (and min) of `--reps` runs. `--out DIR` writes each format's bytes to +//! `DIR/out.` so two builds' output can be compared. +use std::time::Instant; + +use photocraft_doc::Layer; +use photocraft_geom::Rect; + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn photo(w: u32, h: u32) -> photocraft_codecs::Image { + let mut px = vec![0u8; w as usize * h as usize * 3]; + for (y, row) in px.chunks_mut(w as usize * 3).enumerate() { + for x in 0..w as usize { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5; + let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp(); + let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06; + let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06; + let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06; + for (c, v) in [r, g, b].into_iter().enumerate() { + row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + } + photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image") +} + +fn main() { + let args: Vec = std::env::args().collect(); + let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))).unwrap_or((7360u32, 4912u32)); + let reps: usize = arg(&args, "--reps").and_then(|s| s.parse().ok()).unwrap_or(7); + let only = arg(&args, "--only"); + let out = arg(&args, "--out"); + + let png = photocraft_codecs::encode(&photo(w, h), photocraft_codecs::Format::Png, &Default::default()).expect("png"); + let mut doc = photocraft_io::import("photo.png", &png).expect("import").document; + let fmt = doc.pixel_format(); + let mut paint = Layer::raster("paint", fmt); + if let Some(s) = paint.surface_mut() { + let (pw, ph) = (w / 2, h / 3); + let data: Vec = (0..pw * ph) + .flat_map(|i| { + let (x, y) = (i % pw, i / pw); + let a = if (x / 64 + y / 64) % 3 == 0 { 0.0 } else { 0.85 }; + [0.75, (x % 256) as f32 / 255.0, (y % 256) as f32 / 255.0, a] + }) + .collect(); + s.write_region(Rect::from_xywh(w as i32 / 8, h as i32 / 4, pw, ph), &data); + } + doc.layers.push(paint); + let mut soft = Layer::raster("soft", fmt); + soft.opacity = 0.6; + if let Some(s) = soft.surface_mut() { + s.fill_rect(Rect::from_xywh(w as i32 / 2, 0, w / 2, h), &[0.1, 0.2, 0.9, 0.5]); + } + doc.layers.push(soft); + + println!("document {w}×{h}, {} layers, {reps} reps (median / min ms)", doc.layers.len()); + // "pcraft+cache" is the app's Save: one export cache per document, so the first save + // compresses every tile and later ones reuse what fits the cache (timed after a warm-up save). + for ext in ["psd", "pcraft", "pcraft+cache", "tif", "png", "jpg"] { + if only.as_deref().is_some_and(|o| o != ext) { + continue; + } + let cached = ext == "pcraft+cache"; + let opts = photocraft_io::ExportOptions { pcraft_cache: cached.then(photocraft_io::PcraftExportCache::default), ..Default::default() }; + let name = format!("x.{}", ext.trim_end_matches("+cache")); + if cached { + photocraft_io::export(&doc, &name, &opts).expect("warm-up"); + } + let mut times = Vec::with_capacity(reps); + let mut bytes = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + bytes = photocraft_io::export(&doc, &name, &opts).expect("export").bytes; + times.push(t.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + println!("{ext:>7}: {:9.1} / {:9.1} ({} bytes)", times[times.len() / 2], times[0], bytes.len()); + if let Some(dir) = &out { + std::fs::write(format!("{dir}/out.{}", ext.replace('+', "-")), &bytes).expect("write"); + } + } +} diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index cbb2ad303c..c19e4a85d8 100644 --- a/crates/io/examples/gen_abr_library.rs +++ b/crates/io/examples/gen_abr_library.rs @@ -13,7 +13,7 @@ //! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's decoded cap (1 GiB; 512 MiB on 32-bit). #![allow(clippy::unwrap_used, clippy::expect_used)] -use photocraft_paint::procedural::*; +use photocraft_paint::procedural::{bristle_tip, chalk_tip, charcoal_tip, grass_tip, leaf_tip, rake_tip, spatter_tip, sponge_tip, star_tip}; use photocraft_paint::tile::GrayTile; use photocraft_psd::abr::{AbrSample, MAX_TOTAL_BYTES, write_v6}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index fddef567a9..cc4b5aa0de 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -22,9 +22,244 @@ fn main() { corpus_summary(std::path::Path::new(&path)); return; } - let bytes = std::fs::read(&path).unwrap(); + run_file(&path); +} + +fn run_file(path: &str) { + let bytes = std::fs::read(path).unwrap(); let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); - let mut imp = photocraft_io::import(&path, &bytes).unwrap(); + let mut imp = photocraft_io::import(path, &bytes).unwrap(); + configure_visibility(&mut imp.document.layers); + let doc = &imp.document; + println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light); + print_layer_details(doc); + dump_filter_effects(&file); + let ours = photocraft_compose::flatten(doc).px; + let merged = photocraft_io::merged_composite(&file).unwrap(); + compare_modes(doc, &ours, &merged); +} + +fn compare_modes(doc: &photocraft_doc::Document, ours: &[[f32; 4]], merged: &[[f32; 4]]) { + if std::env::args().nth(3).as_deref() == Some("png") { + write_comparison_png(doc, ours, merged); + return; + } + if std::env::args().nth(3).as_deref() == Some("dump") { + // dump prefix: raw little-endian f32 RGBA (straight) planes for offline fitting: + // prefix_ours.f32, prefix_ps.f32, prefix_L.f32 (each top-level layer's own pixels, + // masks not applied) and prefix.txt (width, height, layer names). + let out = std::env::args().nth(4).expect("prefix"); + let wr = |name: String, px: &[[f32; 4]]| { + let bytes: Vec = px.iter().flat_map(|p| p.iter().flat_map(|v| v.to_le_bytes())).collect(); + std::fs::write(name, bytes).unwrap(); + }; + wr(format!("{out}_ours.f32"), ours); + wr(format!("{out}_ps.f32"), merged); + let mut meta = format!("{} {}\n", doc.size.width, doc.size.height); + for (i, l) in doc.layers.iter().enumerate() { + let b = photocraft_compose::surface_to_buffer( + l.surface().unwrap_or(&photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)), + doc.bounds(), + ); + wr(format!("{out}_L{i}.f32"), &b.px); + meta += &format!("{i} {}\n", l.name); + } + std::fs::write(format!("{out}.txt"), meta).unwrap(); + return; + } + if std::env::args().nth(3).as_deref() == Some("layerpx") { + // layerpx x y: each layer's own pixel (0-255). + let x: i32 = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); + let y: i32 = std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0); + for l in doc.walk() { + if let Some(s) = l.2.surface() { + println!("{:?}: {:?}", l.2.name, s.pixel(x, y).iter().map(|v| (v * 255.0 * 100.0).round() / 100.0).collect::>()); + } + } + return; + } + if std::env::args().nth(3).as_deref() == Some("grid") { + // grid x0 y0 x1 y1 [channel]: ours / photoshop values (0-255, premultiplied colour). + let w = doc.size.width as usize; + let a: Vec = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); + let ch: usize = std::env::args().nth(8).and_then(|s| s.parse().ok()).unwrap_or(3); + for (label, img) in [("ours", &ours), ("ps", &merged)] { + println!("{label} (channel {ch}):"); + for y in a[1]..a[3] { + let row: Vec = (a[0]..a[2]) + .map(|x| { + let p = img[y * w + x]; + let v = if ch == 3 { p[3] } else { p[ch] * p[3] }; + format!("{:4}", (v * 255.0).round()) + }) + .collect(); + println!(" y={y:4} {}", row.join("")); + } + } + return; + } + if std::env::args().nth(3).as_deref() == Some("bylayer") { + // Max error and bad-pixel count inside each layer's bounds grown by its effects' reach + // (regions overlap; a quick way to tell which layer or effect is off). + let (w, h) = (doc.size.width as i32, doc.size.height as i32); + for (_, _, l) in doc.walk() { + let Some(sf) = l.surface() else { continue }; + let cb = sf.content_bounds(); + let mut b = photocraft_geom::Rect::new(i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for y in cb.y0..cb.y1 { + for x in cb.x0..cb.x1 { + let p = sf.pixel(x, y); + if p[p.len() - 1] > 0.0 { + b = photocraft_geom::Rect::new(b.x0.min(x), b.y0.min(y), b.x1.max(x + 1), b.y1.max(y + 1)); + } + } + } + if b.x0 > b.x1 { + continue; + } + let m = photocraft_compose::effects::margin(l); + let r = photocraft_geom::Rect::new((b.x0 - m).max(0), (b.y0 - m).max(0), (b.x1 + m).min(w), (b.y1 + m).min(h)); + let (mut worst, mut at, mut bad) = (0.0f32, (0, 0), 0usize); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let i = (y * w + x) as usize; + let (a, b) = (ours[i], merged[i]); + let d = (0..3).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max); + if d > 2.0 / 255.0 { + bad += 1; + } + if d > worst { + (worst, at) = (d, (x, y)); + } + } + } + let fx: Vec<&str> = l.effects.items.iter().filter(|e| e.enabled()).map(|e| e.label()).collect(); + println!("{:<32} {:>6.1} at {:?} bad {:>6} {:?} {:?}", l.name, worst * 255.0, at, bad, l.blend, fx); + } + return; + } + if std::env::args().nth(3).as_deref() == Some("worst") { + // The N worst pixels (premultiplied max channel error), with coordinates. + let w = doc.size.width as usize; + let n: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(20); + let mut v: Vec<(f32, usize)> = ours + .iter() + .zip(merged) + .enumerate() + .map(|(i, (a, b))| ((0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max), i)) + .collect(); + v.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (d, i) in v.into_iter().take(n) { + let (a, b) = (ours[i], merged[i]); + println!("({:4},{:4}) d={:5.1} ours {:?} ps {:?}", i % w, i / w, d * 255.0, a.map(|v| (v * 255.0).round()), b.map(|v| (v * 255.0).round())); + } + return; + } + if std::env::args().nth(3).as_deref() == Some("row") { + let w = doc.size.width as usize; + let y: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); + let (x0, x1): (usize, usize) = + (std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w)); + for x in (x0..x1).step_by(((x1 - x0) / 20).max(1)) { + let (a, b) = (ours[y * w + x], merged[y * w + x]); + println!("x={x:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } + return; + } + if std::env::args().nth(3).as_deref() == Some("solve") { + // solve layer-name x0 y0 x1 y1: per pixel the layer's content alpha `l`, its shape's path + // coverage `cov`, Photoshop's alpha `A` and the alpha `s` an effect beneath the layer + // would need (A = s + l (1 - s)) over a transparent backdrop. + let name = std::env::args().nth(4).expect("layer name"); + let a: Vec = (5..9).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); + let w = doc.size.width as usize; + let l = doc.walk().into_iter().map(|t| t.2).find(|l| l.name == name).expect("layer"); + let r = photocraft_geom::Rect::new(a[0], a[1], a[2], a[3]); + let cov = match &l.content { + photocraft_doc::LayerContent::Shape(sh) => photocraft_vector::path_coverage(&sh.path, r), + _ => vec![0.0; r.width() as usize * r.height() as usize], + }; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let la = l.surface().map_or(0.0, |s| s.pixel(x, y)[3]); + let c = cov[((y - r.y0) * r.width() as i32 + x - r.x0) as usize]; + let p = merged[y as usize * w + x as usize]; + let o = ours[y as usize * w + x as usize]; + let s = if la < 1.0 { (p[3] - la) / (1.0 - la) } else { f32::NAN }; + println!("({x:3},{y:3}) l {la:.3} cov {c:.3} A {:.3} ours {:.3} s {s:.3} ps {:?}", p[3], o[3], p.map(|v| (v * 255.0).round())); + } + } + return; + } + if std::env::args().nth(3).as_deref() == Some("line") { + // line x0 y0 x1 y1: every pixel on the segment, straight RGBA 0-255, ours | ps. + let w = doc.size.width as usize; + let a: Vec = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); + let n = (a[2] - a[0]).abs().max((a[3] - a[1]).abs()).max(1); + for k in 0..=n { + let (x, y) = ((a[0] + (a[2] - a[0]) * k / n) as usize, (a[1] + (a[3] - a[1]) * k / n) as usize); + let (o, p) = (ours[y * w + x], merged[y * w + x]); + println!("({x:4},{y:4}) ours {:?} ps {:?}", o.map(|v| (v * 255.0).round()), p.map(|v| (v * 255.0).round())); + } + return; + } + if std::env::args().nth(3).as_deref() == Some("col") { + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let x = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(w / 2); + for k in 0..=16 { + let y = ((h - 1) * k / 16).min(h - 1); + let (a, b) = (ours[y * w + x], merged[y * w + x]); + println!("y={y:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } + return; + } + let n = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(8usize); + for (i, (a, b)) in ours.iter().zip(merged).enumerate().take(n) { + println!("{i:4}: ours {:?}\n ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } +} + +fn write_comparison_png(doc: &photocraft_doc::Document, ours: &[[f32; 4]], merged: &[[f32; 4]]) { + // ours | photoshop | diff heatmap, side by side (over white). + let out = std::env::args().nth(4).expect("out.png"); + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let mut img = vec![0u8; w * 3 * h * 4]; + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + for y in 0..h { + for x in 0..w { + let (a, b) = (ours[y * w + x], merged[y * w + x]); + let over = |p: [f32; 4]| [p[0] * p[3] + 1.0 - p[3], p[1] * p[3] + 1.0 - p[3], p[2] * p[3] + 1.0 - p[3]]; + let (oa, ob) = (over(a), over(b)); + let d = (0..3).map(|c| (oa[c] - ob[c]).abs()).fold(0.0f32, f32::max); + let heat = [q((d * 4.0).min(1.0)), q((1.0 - d * 4.0).max(0.0) * 0.3), 0]; + for (k, px) in [[q(oa[0]), q(oa[1]), q(oa[2])], [q(ob[0]), q(ob[1]), q(ob[2])], heat].iter().enumerate() { + let o = (y * w * 3 + k * w + x) * 4; + img[o..o + 3].copy_from_slice(px); + img[o + 3] = 255; + } + } + } + // SCALE=n: nearest-neighbour upscale (small files). + let k: usize = std::env::var("SCALE").ok().and_then(|s| s.parse().ok()).unwrap_or(1).clamp(1, 16); + let (img, w, h) = if k > 1 { + let mut big = vec![0u8; w * 3 * k * h * k * 4]; + for y in 0..h * k { + for x in 0..w * 3 * k { + let (s, d) = (((y / k) * w * 3 + x / k) * 4, (y * w * 3 * k + x) * 4); + big[d..d + 4].copy_from_slice(&img[s..s + 4]); + } + } + (big, w * k, h * k) + } else { + (img, w, h) + }; + let image = + photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img).unwrap(); + std::fs::write(&out, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + println!("wrote {out}"); +} + +fn configure_visibility(layers: &mut [photocraft_doc::Layer]) { if std::env::var_os("HIDE_ADJ").is_some() { // Composite without adjustment layers (their input, for fitting transfer curves). fn hide(ls: &mut [photocraft_doc::Layer]) { @@ -37,18 +272,19 @@ fn main() { } } } - hide(&mut imp.document.layers); + hide(layers); } if let Some(keep) = std::env::var_os("ONLY") { // Hide every top-level layer but the bottom one and those named in ONLY (comma-separated). let keep = keep.to_string_lossy().to_string(); let names: Vec<&str> = keep.split(',').collect(); - for l in imp.document.layers.iter_mut().skip(1) { + for l in layers.iter_mut().skip(1) { l.visible = names.contains(&l.name.as_str()); } } - let doc = &imp.document; - println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light); +} + +fn print_layer_details(doc: &photocraft_doc::Document) { for l in doc.walk() { let l = l.2; println!( @@ -184,6 +420,9 @@ fn main() { } } } +} + +fn dump_filter_effects(file: &photocraft_psd::PsdFile) { if std::env::var_os("DUMP_FX").is_some() { fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize) { for (k, v) in &d.items { @@ -219,222 +458,6 @@ fn main() { } } } - let ours = photocraft_compose::flatten(doc).px; - let merged = photocraft_io::merged_composite(&file).unwrap(); - if std::env::args().nth(3).as_deref() == Some("png") { - // ours | photoshop | diff heatmap, side by side (over white). - let out = std::env::args().nth(4).expect("out.png"); - let (w, h) = (doc.size.width as usize, doc.size.height as usize); - let mut img = vec![0u8; w * 3 * h * 4]; - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - for y in 0..h { - for x in 0..w { - let (a, b) = (ours[y * w + x], merged[y * w + x]); - let over = |p: [f32; 4]| [p[0] * p[3] + 1.0 - p[3], p[1] * p[3] + 1.0 - p[3], p[2] * p[3] + 1.0 - p[3]]; - let (oa, ob) = (over(a), over(b)); - let d = (0..3).map(|c| (oa[c] - ob[c]).abs()).fold(0.0f32, f32::max); - let heat = [q((d * 4.0).min(1.0)), q((1.0 - d * 4.0).max(0.0) * 0.3), 0]; - for (k, px) in [[q(oa[0]), q(oa[1]), q(oa[2])], [q(ob[0]), q(ob[1]), q(ob[2])], heat].iter().enumerate() { - let o = (y * w * 3 + k * w + x) * 4; - img[o..o + 3].copy_from_slice(px); - img[o + 3] = 255; - } - } - } - // SCALE=n: nearest-neighbour upscale (small files). - let k: usize = std::env::var("SCALE").ok().and_then(|s| s.parse().ok()).unwrap_or(1).clamp(1, 16); - let (img, w, h) = if k > 1 { - let mut big = vec![0u8; w * 3 * k * h * k * 4]; - for y in 0..h * k { - for x in 0..w * 3 * k { - let (s, d) = (((y / k) * w * 3 + x / k) * 4, (y * w * 3 * k + x) * 4); - big[d..d + 4].copy_from_slice(&img[s..s + 4]); - } - } - (big, w * k, h * k) - } else { - (img, w, h) - }; - let image = - photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img) - .unwrap(); - std::fs::write(&out, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); - println!("wrote {out}"); - return; - } - if std::env::args().nth(3).as_deref() == Some("dump") { - // dump prefix: raw little-endian f32 RGBA (straight) planes for offline fitting: - // prefix_ours.f32, prefix_ps.f32, prefix_L.f32 (each top-level layer's own pixels, - // masks not applied) and prefix.txt (width, height, layer names). - let out = std::env::args().nth(4).expect("prefix"); - let wr = |name: String, px: &[[f32; 4]]| { - let bytes: Vec = px.iter().flat_map(|p| p.iter().flat_map(|v| v.to_le_bytes())).collect(); - std::fs::write(name, bytes).unwrap(); - }; - wr(format!("{out}_ours.f32"), &ours); - wr(format!("{out}_ps.f32"), &merged); - let mut meta = format!("{} {}\n", doc.size.width, doc.size.height); - for (i, l) in doc.layers.iter().enumerate() { - let b = photocraft_compose::surface_to_buffer( - l.surface().unwrap_or(&photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)), - doc.bounds(), - ); - wr(format!("{out}_L{i}.f32"), &b.px); - meta += &format!("{i} {}\n", l.name); - } - std::fs::write(format!("{out}.txt"), meta).unwrap(); - return; - } - if std::env::args().nth(3).as_deref() == Some("layerpx") { - // layerpx x y: each layer's own pixel (0-255). - let x: i32 = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); - let y: i32 = std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0); - for l in doc.walk() { - if let Some(s) = l.2.surface() { - println!("{:?}: {:?}", l.2.name, s.pixel(x, y).iter().map(|v| (v * 255.0 * 100.0).round() / 100.0).collect::>()); - } - } - return; - } - if std::env::args().nth(3).as_deref() == Some("grid") { - // grid x0 y0 x1 y1 [channel]: ours / photoshop values (0-255, premultiplied colour). - let w = doc.size.width as usize; - let a: Vec = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); - let ch: usize = std::env::args().nth(8).and_then(|s| s.parse().ok()).unwrap_or(3); - for (label, img) in [("ours", &ours), ("ps", &merged)] { - println!("{label} (channel {ch}):"); - for y in a[1]..a[3] { - let row: Vec = (a[0]..a[2]) - .map(|x| { - let p = img[y * w + x]; - let v = if ch == 3 { p[3] } else { p[ch] * p[3] }; - format!("{:4}", (v * 255.0).round()) - }) - .collect(); - println!(" y={y:4} {}", row.join("")); - } - } - return; - } - if std::env::args().nth(3).as_deref() == Some("bylayer") { - // Max error and bad-pixel count inside each layer's bounds grown by its effects' reach - // (regions overlap; a quick way to tell which layer or effect is off). - let (w, h) = (doc.size.width as i32, doc.size.height as i32); - for (_, _, l) in doc.walk() { - let Some(sf) = l.surface() else { continue }; - let cb = sf.content_bounds(); - let mut b = photocraft_geom::Rect::new(i32::MAX, i32::MAX, i32::MIN, i32::MIN); - for y in cb.y0..cb.y1 { - for x in cb.x0..cb.x1 { - let p = sf.pixel(x, y); - if p[p.len() - 1] > 0.0 { - b = photocraft_geom::Rect::new(b.x0.min(x), b.y0.min(y), b.x1.max(x + 1), b.y1.max(y + 1)); - } - } - } - if b.x0 > b.x1 { - continue; - } - let m = photocraft_compose::effects::margin(l); - let r = photocraft_geom::Rect::new((b.x0 - m).max(0), (b.y0 - m).max(0), (b.x1 + m).min(w), (b.y1 + m).min(h)); - let (mut worst, mut at, mut bad) = (0.0f32, (0, 0), 0usize); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let i = (y * w + x) as usize; - let (a, b) = (ours[i], merged[i]); - let d = (0..3).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max); - if d > 2.0 / 255.0 { - bad += 1; - } - if d > worst { - (worst, at) = (d, (x, y)); - } - } - } - let fx: Vec<&str> = l.effects.items.iter().filter(|e| e.enabled()).map(|e| e.label()).collect(); - println!("{:<32} {:>6.1} at {:?} bad {:>6} {:?} {:?}", l.name, worst * 255.0, at, bad, l.blend, fx); - } - return; - } - if std::env::args().nth(3).as_deref() == Some("worst") { - // The N worst pixels (premultiplied max channel error), with coordinates. - let w = doc.size.width as usize; - let n: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(20); - let mut v: Vec<(f32, usize)> = ours - .iter() - .zip(&merged) - .enumerate() - .map(|(i, (a, b))| ((0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max), i)) - .collect(); - v.sort_by(|a, b| b.0.total_cmp(&a.0)); - for (d, i) in v.into_iter().take(n) { - let (a, b) = (ours[i], merged[i]); - println!("({:4},{:4}) d={:5.1} ours {:?} ps {:?}", i % w, i / w, d * 255.0, a.map(|v| (v * 255.0).round()), b.map(|v| (v * 255.0).round())); - } - return; - } - if std::env::args().nth(3).as_deref() == Some("row") { - let w = doc.size.width as usize; - let y: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); - let (x0, x1): (usize, usize) = - (std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w)); - for x in (x0..x1).step_by(((x1 - x0) / 20).max(1)) { - let (a, b) = (ours[y * w + x], merged[y * w + x]); - println!("x={x:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); - } - return; - } - if std::env::args().nth(3).as_deref() == Some("solve") { - // solve layer-name x0 y0 x1 y1: per pixel the layer's content alpha `l`, its shape's path - // coverage `cov`, Photoshop's alpha `A` and the alpha `s` an effect beneath the layer - // would need (A = s + l (1 - s)) over a transparent backdrop. - let name = std::env::args().nth(4).expect("layer name"); - let a: Vec = (5..9).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); - let w = doc.size.width as usize; - let l = doc.walk().into_iter().map(|t| t.2).find(|l| l.name == name).expect("layer"); - let r = photocraft_geom::Rect::new(a[0], a[1], a[2], a[3]); - let cov = match &l.content { - photocraft_doc::LayerContent::Shape(sh) => photocraft_vector::path_coverage(&sh.path, r), - _ => vec![0.0; r.width() as usize * r.height() as usize], - }; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let la = l.surface().map_or(0.0, |s| s.pixel(x, y)[3]); - let c = cov[((y - r.y0) * r.width() as i32 + x - r.x0) as usize]; - let p = merged[y as usize * w + x as usize]; - let o = ours[y as usize * w + x as usize]; - let s = if la < 1.0 { (p[3] - la) / (1.0 - la) } else { f32::NAN }; - println!("({x:3},{y:3}) l {la:.3} cov {c:.3} A {:.3} ours {:.3} s {s:.3} ps {:?}", p[3], o[3], p.map(|v| (v * 255.0).round())); - } - } - return; - } - if std::env::args().nth(3).as_deref() == Some("line") { - // line x0 y0 x1 y1: every pixel on the segment, straight RGBA 0-255, ours | ps. - let w = doc.size.width as usize; - let a: Vec = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect(); - let n = (a[2] - a[0]).abs().max((a[3] - a[1]).abs()).max(1); - for k in 0..=n { - let (x, y) = ((a[0] + (a[2] - a[0]) * k / n) as usize, (a[1] + (a[3] - a[1]) * k / n) as usize); - let (o, p) = (ours[y * w + x], merged[y * w + x]); - println!("({x:4},{y:4}) ours {:?} ps {:?}", o.map(|v| (v * 255.0).round()), p.map(|v| (v * 255.0).round())); - } - return; - } - if std::env::args().nth(3).as_deref() == Some("col") { - let (w, h) = (doc.size.width as usize, doc.size.height as usize); - let x = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(w / 2); - for k in 0..=16 { - let y = ((h - 1) * k / 16).min(h - 1); - let (a, b) = (ours[y * w + x], merged[y * w + x]); - println!("y={y:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); - } - return; - } - let n = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(8usize); - for (i, (a, b)) in ours.iter().zip(&merged).enumerate().take(n) { - println!("{i:4}: ours {:?}\n ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); - } } /// The corpus oracle table (as `tests/it/corpus.rs`), files in parallel, without a test build. @@ -467,7 +490,7 @@ fn corpus_summary(root: &std::path::Path) { let ours = photocraft_compose::flatten(&imp.document).px; let mut m = 0.0f32; let mut bad = 0usize; - for (a, b) in ours.iter().zip(&merged) { + for (a, b) in ours.iter().zip(merged) { let d = (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max); m = m.max(d); if (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > TOL) { diff --git a/crates/io/examples/psd_open_mem.rs b/crates/io/examples/psd_open_mem.rs new file mode 100644 index 0000000000..903eb35af9 --- /dev/null +++ b/crates/io/examples/psd_open_mem.rs @@ -0,0 +1,56 @@ +//! Opens a file the way the app's background open does and reports the time. Run it under +//! `/usr/bin/time -l` for the peak memory. `--read` reads the whole file first and imports the +//! bytes, as opens did before `import_file` (to compare). +//! +//! `cargo run --release -p photocraft-io --example psd_open_mem -- [--read]` + +fn main() { + let Some(path) = std::env::args().nth(1).filter(|a| a != "--read") else { + eprintln!("usage: psd_open_mem "); + std::process::exit(2); + }; + if std::env::args().any(|a| a == "--read") { + read_then_import(&path); + return; + } + let t = std::time::Instant::now(); + let res = + match photocraft_io::import_file(&path, std::path::Path::new(&path), &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) { + Ok(r) => r, + Err(e) => { + eprintln!("{path}: open failed: {e}"); + std::process::exit(1); + } + }; + report(&path, &format!("open {:.2}s", t.elapsed().as_secs_f64()), &res); +} + +fn read_then_import(path: &str) { + let t = std::time::Instant::now(); + let bytes = match photocraft_format::read_file(std::path::Path::new(path)) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: read failed: {e}"); + std::process::exit(1); + } + }; + let read_s = t.elapsed().as_secs_f64(); + let t = std::time::Instant::now(); + let res = match photocraft_io::import_with(path, &bytes, &photocraft_raster::Interrupt::NONE) { + Ok(r) => r, + Err(e) => { + eprintln!("{path}: open failed: {e}"); + std::process::exit(1); + } + }; + drop(bytes); + report(path, &format!("read {read_s:.2}s, import {:.2}s", t.elapsed().as_secs_f64()), &res); +} + +fn report(path: &str, times: &str, res: &photocraft_io::ImportResult) { + let doc = &res.document; + println!("{path}: {times}, {} layers ({} top level)", doc.walk().len(), doc.layers.len()); + for w in &res.warnings { + println!(" warning: {w}"); + } +} diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 2cac91c4a0..463c882efb 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -11,9 +11,11 @@ //! stay [`Adjustment::Unsupported`] and are written back verbatim. use photocraft_doc::Adjustment; -use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, ToneSpace, curve_map, curve_map_outputs}; +use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map}; use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; +mod encode; + /// All PSD adjustment keys recognized as adjustment layers. pub const ADJUSTMENT_KEYS: [&[u8; 4]; 16] = [b"levl", b"curv", b"hue2", b"brit", b"nvrt", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", b"selc", b"blwh", b"clrL"]; @@ -459,12 +461,12 @@ fn read_lookup(d: &[u8]) -> Result { Some(_) => return Err("its LUT data is empty".into()), None => return Err("it uses a colour profile (abstract or device link), not a LUT file, which isn't supported yet".into()), }; - let (ext, format) = match desc_enum(&desc, "LUTFormat") { - Some(b"LUTFormat3DL") => ("x.3dl", "3DL"), - Some(b"LUTFormatLOOK") => ("x.look", "LOOK"), - _ => ("x.cube", "CUBE"), + let format = match desc_enum(&desc, "LUTFormat") { + Some(b"LUTFormat3DL") => LutFormat::ThreeDl, + Some(b"LUTFormatLOOK") => LutFormat::Look, + _ => LutFormat::Cube, }; - let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?; + let lut = parse_lut_source(format, bytes).map_err(|e| format!("its {} LUT can't be read ({e})", psd_lut_format(format).trim_start_matches("LUTFormat")))?; if !lut.domain_is_default() { return Err(format!( "its LUT has a non-default input domain (DOMAIN_MIN {:?}, DOMAIN_MAX {:?}), which is not supported yet", @@ -478,9 +480,47 @@ fn read_lookup(d: &[u8]) -> Result { lut: Some(std::sync::Arc::new(lut.data)), tetrahedral: false, dither: desc_bool(&desc, "Dthr").unwrap_or(false), + // Kept so a save embeds the file as loaded (Photoshop never converts it). + source: Some(LutSource { format, bytes: std::sync::Arc::new(bytes.clone()) }), }) } +/// A LUT file's table, read the way a PSD read does (an XML file tagged `.cube` is sniffed as `.look`). +fn parse_lut_source(format: LutFormat, bytes: &[u8]) -> Result { + let format = match format { + LutFormat::Cube => photocraft_cms::lutfile::Format::Cube, + LutFormat::ThreeDl => photocraft_cms::lutfile::Format::ThreeDl, + LutFormat::Look => photocraft_cms::lutfile::Format::Look, + }; + photocraft_cms::lutfile::parse(&format!("x.{}", format.extension()), bytes) +} + +/// Photoshop's `LUTFormatType` value for a format. +fn psd_lut_format(format: LutFormat) -> &'static str { + match format { + LutFormat::Cube => "LUTFormatCUBE", + LutFormat::ThreeDl => "LUTFormat3DL", + LutFormat::Look => "LUTFormatLOOK", + } +} + +/// The source file of a Color Lookup to embed as is: only while it still parses to the +/// layer's table, so an edited table is never saved with stale bytes. +fn lut_source_for<'a>(source: Option<&'a LutSource>, table: &[f32], size: usize) -> Option<&'a LutSource> { + let source = source?; + let file = parse_lut_source(source.format, &source.bytes).ok()?; + (file.size == size && file.data == table).then_some(source) +} + +/// `name` with a `.3dl` / `.look` extension swapped for `.cube`, for a table saved as `.cube`. +fn cube_file_name(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + match [".3dl", ".look"].iter().find(|ext| lower.ends_with(*ext)) { + Some(ext) => format!("{}.cube", name.get(..name.len() - ext.len()).unwrap_or(name)), + None => name.to_string(), + } +} + /// The descriptor of a `vibA` / `blwh` block: a 4-byte descriptor version (16), then the descriptor. fn versioned(d: &[u8]) -> Option { VersionedDescriptor::parse_prefix(d).ok().map(|(v, _)| v.descriptor) @@ -630,206 +670,37 @@ fn levels_write(v: &mut Vec, c: &LevelsChannel) { /// (Brightness/Contrast also writes a `CgEd` descriptor). Every evaluated kind has an /// encoding; the result is empty only for an [`Adjustment::Unsupported`] with a malformed key. pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { - let mut v = Vec::new(); + let block = |key: &[u8; 4], data: Vec| vec![(*key, data)]; match adj { - Adjustment::Invert => return vec![(*b"nvrt", Vec::new())], - Adjustment::Threshold { level } => { - put16(&mut v, q255(*level).max(1)); - put16(&mut v, 0); - return vec![(*b"thrs", v)]; - } - Adjustment::Posterize { levels } => { - put16(&mut v, (*levels).clamp(2, 255) as u16); - put16(&mut v, 0); - return vec![(*b"post", v)]; - } - Adjustment::BrightnessContrast { brightness, contrast, legacy } => { - v.extend_from_slice(&clamp_i16(*brightness).to_be_bytes()); - v.extend_from_slice(&clamp_i16(*contrast).to_be_bytes()); - put16(&mut v, 127); - v.push(0); - let d = Descriptor::new("null") - .with("Vrsn", Value::Integer(1)) - .with("Brgh", Value::Integer(brightness.round() as i32)) - .with("Cntr", Value::Integer(contrast.round() as i32)) - .with("means", Value::Integer(127)) - .with("Lab ", Value::Boolean(false)) - .with("useLegacy", Value::Boolean(*legacy)) - .with("Auto", Value::Boolean(false)); - return vec![(*b"brit", v), (*b"CgEd", VersionedDescriptor::new(d).to_bytes())]; - } + Adjustment::Invert => return block(b"nvrt", Vec::new()), + Adjustment::Threshold { level } => return block(b"thrs", encode::words(&[q255(*level).max(1), 0])), + Adjustment::Posterize { levels } => return block(b"post", encode::words(&[(*levels).clamp(2, 255) as u16, 0])), + Adjustment::BrightnessContrast { brightness, contrast, legacy } => return encode::brightness_contrast(*brightness, *contrast, *legacy), Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { - put16(&mut v, 2); - v.push(u8::from(*colorize)); - v.push(0); - // The colorize triplet, then the master triplet. The inactive one gets Photoshop's - // defaults; the colorize hue (0..360 in the model) is stored signed, -180..180. - let (colorize_hsl, master) = if *colorize { - let h = hue.rem_euclid(360.0); - ([if h > 180.0 { h - 360.0 } else { h }, *saturation, *lightness], [0.0; 3]) - } else { - ([30.0, 25.0, 0.0], [*hue, *saturation, *lightness]) - }; - for x in colorize_hsl.into_iter().chain(master) { - v.extend_from_slice(&clamp_i16(x).to_be_bytes()); - } - // Six hue ranges (reds, yellows, greens, cyans, blues, magentas): range values in - // 0..360, then the range's hue, saturation and lightness. - for r in ranges { - for x in r.bounds { - put16(&mut v, x.rem_euclid(360.0).round() as u16 % 360); - } - for x in [r.hue, r.saturation, r.lightness] { - v.extend_from_slice(&clamp_i16(x).to_be_bytes()); - } - } - return vec![(*b"hue2", v)]; - } - Adjustment::Exposure { exposure, offset, gamma } => { - put16(&mut v, 1); - for x in [exposure, offset, gamma] { - v.extend_from_slice(&x.to_be_bytes()); - } - v.push(1); // color space flag (spec: "1 byte") - return vec![(*b"expA", v)]; - } - Adjustment::Levels { master, per_channel, space, black } => { - put16(&mut v, 2); - levels_write(&mut v, master); - for c in per_channel { - levels_write(&mut v, c); - } - let ident = LevelsChannel::default(); - levels_write(&mut v, if *space == ToneSpace::Cmyk { black } else { &ident }); - for _ in 5..29 { - levels_write(&mut v, &LevelsChannel::default()); - } - return vec![(*b"levl", v)]; + return block(b"hue2", encode::hue_saturation([*hue, *saturation, *lightness], *colorize, ranges)); } + Adjustment::Exposure { exposure, offset, gamma } => return block(b"expA", encode::exposure(*exposure, *offset, *gamma)), + Adjustment::Levels { master, per_channel, space, black } => return block(b"levl", encode::levels(master, per_channel, *space, black)), Adjustment::Curves { master, per_channel, space, black, pencil } => { - v.push(u8::from(*pencil)); - put16(&mut v, 1); - let ink = *space == ToneSpace::Cmyk && black.len() >= 2; - v.extend_from_slice(&(if ink { 0b11111u32 } else { 0b1111 }).to_be_bytes()); - for c in std::iter::once(master).chain(per_channel.iter()).chain(ink.then_some(black)) { - if *pencil { - v.extend(curve_map_outputs(c).iter().map(|&o| u8::try_from(q255(o)).unwrap_or(u8::MAX))); - continue; - } - put16(&mut v, c.len().min(19) as u16); - for p in c.iter().take(19) { - put16(&mut v, q255(p.output)); - put16(&mut v, q255(p.input)); - } - } - return vec![(*b"curv", v)]; + return block(b"curv", encode::curves(master, per_channel, *space, black, *pencil)); } - Adjustment::SelectiveColor { relative, adjustments } => { - put16(&mut v, 1); - put16(&mut v, u16::from(!*relative)); - v.extend_from_slice(&[0; 8]); - for rec in adjustments { - for x in rec { - v.extend_from_slice(&clamp_i16(x.clamp(-100.0, 100.0)).to_be_bytes()); - } - } - return vec![(*b"selc", v)]; - } - Adjustment::ColorLookup { name, lut, size, dither, .. } => { - // A lookup without a usable table (none chosen yet, or a bad size) renders as the - // identity, so it is written as a 2³ identity cube rather than dropped. - let n = *size as usize; - let cube = lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)).and_then(|(table, len)| table.get(..len)).filter(|_| n <= 256); - let (file, dither) = match cube { - Some(cube) => ( - photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] }, - *dither, - ), - _ => (photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }, false), - }; - let en = - |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; - let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); - let d = Descriptor::new("null") - .with("lookupType", en("colorLookupType", "3DLUT")) - .with("NM ", text(name)) - .with("Dthr", Value::Boolean(dither)) - .with("profile", Value::RawData(Vec::new())) - .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) - .with("dataOrder", en("colorLookupOrder", "rgbOrder")) - .with("tableOrder", en("colorLookupOrder", "bgrOrder")) - .with("LUT3DFileData", Value::RawData(photocraft_cms::lutfile::write_cube(&file).into_bytes())) - .with("LUT3DFileName", text(name)); - put16(&mut v, 1); - v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); - return vec![(*b"clrL", v)]; + Adjustment::SelectiveColor { relative, adjustments } => return block(b"selc", encode::selective_color(*relative, adjustments)), + Adjustment::ColorLookup { name, lut, size, dither, source, .. } => { + return block(b"clrL", encode::color_lookup(name, lut.as_deref().map(Vec::as_slice), *size, *dither, source.as_ref())); } Adjustment::GradientMap { stops, reverse, dither } => { - // Photoshop needs two stops; fewer are written as the gradient they render as - // (none: black to white, i.e. the gray value itself; one: that colour throughout). - let stops = match stops.as_slice() { - [] => vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], - [(_, c)] => vec![(0.0, *c), (1.0, *c)], - s => s.to_vec(), - }; - return vec![(*b"grdm", write_gradient_map(&stops, *reverse, *dither))]; - } - Adjustment::Vibrance { vibrance, saturation } => { - let d = Descriptor::new("null") - .with("vibrance", Value::Integer(i32::from(clamp_i16(vibrance.clamp(-100.0, 100.0))))) - .with("Strt", Value::Integer(i32::from(clamp_i16(saturation.clamp(-100.0, 100.0))))); - return vec![(*b"vibA", VersionedDescriptor::new(d).to_bytes())]; + return block(b"grdm", write_gradient_map(&encode::gradient_stops(stops), *reverse, *dither)); } + Adjustment::Vibrance { vibrance, saturation } => return block(b"vibA", encode::vibrance(*vibrance, *saturation)), Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { - for x in shadows.iter().chain(midtones).chain(highlights) { - v.extend_from_slice(&clamp_i16(x.clamp(-100.0, 100.0)).to_be_bytes()); - } - v.push(u8::from(*preserve_luminosity)); - v.push(0); - return vec![(*b"blnc", v)]; - } - Adjustment::BlackWhite { weights, tint } => { - let mut d = Descriptor::new("null"); - for (w, k) in weights.iter().zip(BW_KEYS) { - d = d.with(k, Value::Integer(i32::from(clamp_i16(w.clamp(-200.0, 300.0))))); - } - let d = d - .with("useTint", Value::Boolean(tint.is_some())) - .with("tintColor", Value::Descriptor(rgb_desc(tint.unwrap_or(BW_TINT)))) - .with("bwPresetKind", Value::Integer(1)) - .with("blackAndWhitePresetFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(""))); - return vec![(*b"blwh", VersionedDescriptor::new(d).to_bytes())]; + return block(b"blnc", encode::color_balance([shadows, midtones, highlights], *preserve_luminosity)); } + Adjustment::BlackWhite { weights, tint } => return block(b"blwh", encode::black_white(weights, *tint)), Adjustment::PhotoFilter { color, density, preserve_luminosity } => { - // Version 2: an RGB colour structure keeps the colour at 16 bits. - put16(&mut v, 2); - put16(&mut v, 0); - for x in color { - put16(&mut v, (x.clamp(0.0, 1.0) * 65535.0).round() as u16); - } - put16(&mut v, 0); - v.extend_from_slice(&((density.clamp(0.0, 1.0) * 100.0).round() as u32).to_be_bytes()); - v.push(u8::from(*preserve_luminosity)); - // Photoshop requires the 17-byte Photo Filter payload after the version to be - // padded to a 4-byte boundary INSIDE the tagged block's declared length. - // A single pad byte made an 18-byte `phfl` block that Photoshop could not open - // even though PhotoCraft and other tolerant readers accepted it (#1455). - v.extend_from_slice(&[0, 0, 0]); - return vec![(*b"phfl", v)]; + return block(b"phfl", encode::photo_filter(*color, *density, *preserve_luminosity)); } Adjustment::ChannelMixer { matrix, monochrome } => { - put16(&mut v, 1); - put16(&mut v, u16::from(*monochrome)); - // Red, green, blue, then the gray record (the first row, which monochrome uses). - for row in matrix.iter().chain(std::iter::once(&matrix[0])) { - let pct = |x: f32| clamp_i16((x * 100.0).clamp(-200.0, 200.0)).to_be_bytes(); - for x in &row[..3] { - v.extend_from_slice(&pct(*x)); - } - v.extend_from_slice(&[0, 0]); - v.extend_from_slice(&pct(row[3])); - } - return vec![(*b"mixr", v)]; + return block(b"mixr", encode::channel_mixer(matrix, *monochrome)); } Adjustment::Unsupported { psd_key, raw } => { let k = psd_key.as_bytes(); diff --git a/crates/io/src/adjust_map/encode.rs b/crates/io/src/adjust_map/encode.rs new file mode 100644 index 0000000000..08d3075b57 --- /dev/null +++ b/crates/io/src/adjust_map/encode.rs @@ -0,0 +1,236 @@ +use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map_outputs}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; + +use super::{BW_KEYS, BW_TINT, clamp_i16, cube_file_name, levels_write, lut_source_for, psd_lut_format, put16, q255, rgb_desc}; + +/// The values as big-endian 16-bit words. +pub(super) fn words(xs: &[u16]) -> Vec { + let mut v = Vec::new(); + for &x in xs { + put16(&mut v, x); + } + v +} + +/// Appends `x` as a clamped big-endian 16-bit integer. +fn push_i16(v: &mut Vec, x: f32) { + v.extend_from_slice(&clamp_i16(x).to_be_bytes()); +} + +/// The legacy Brightness/Contrast block and its descriptor block. +pub(super) fn brightness_contrast(brightness: f32, contrast: f32, legacy: bool) -> Vec<([u8; 4], Vec)> { + let mut v = Vec::new(); + push_i16(&mut v, brightness); + push_i16(&mut v, contrast); + put16(&mut v, 127); + v.push(0); + let d = Descriptor::new("null") + .with("Vrsn", Value::Integer(1)) + .with("Brgh", Value::Integer(brightness.round() as i32)) + .with("Cntr", Value::Integer(contrast.round() as i32)) + .with("means", Value::Integer(127)) + .with("Lab ", Value::Boolean(false)) + .with("useLegacy", Value::Boolean(legacy)) + .with("Auto", Value::Boolean(false)); + vec![(*b"brit", v), (*b"CgEd", VersionedDescriptor::new(d).to_bytes())] +} + +/// Hue/Saturation payload: the master values, then the six hue ranges. +pub(super) fn hue_saturation(hsl: [f32; 3], colorize: bool, ranges: &[HueRange; 6]) -> Vec { + let mut v = words(&[2]); + v.push(u8::from(colorize)); + v.push(0); + // The colorize triplet, then the master triplet. The inactive one gets Photoshop's + // defaults; the colorize hue (0..360 in the model) is stored signed, -180..180. + let [hue, saturation, lightness] = hsl; + let (colorize_hsl, master) = if colorize { + let h = hue.rem_euclid(360.0); + ([if h > 180.0 { h - 360.0 } else { h }, saturation, lightness], [0.0; 3]) + } else { + ([30.0, 25.0, 0.0], hsl) + }; + for x in colorize_hsl.into_iter().chain(master) { + push_i16(&mut v, x); + } + // Six hue ranges: four bounds in 0..360, then hue, saturation and lightness. + for r in ranges { + for x in r.bounds { + put16(&mut v, x.rem_euclid(360.0).round() as u16 % 360); + } + for x in [r.hue, r.saturation, r.lightness] { + push_i16(&mut v, x); + } + } + v +} + +/// Exposure payload. +pub(super) fn exposure(exposure: f32, offset: f32, gamma: f32) -> Vec { + let mut v = words(&[1]); + for x in [exposure, offset, gamma] { + v.extend_from_slice(&x.to_be_bytes()); + } + v.push(1); // color space flag (spec: "1 byte") + v +} + +/// Levels payload: master, the three colour channels, black (CMYK only), then identity records. +pub(super) fn levels(master: &LevelsChannel, per_channel: &[LevelsChannel; 3], space: ToneSpace, black: &LevelsChannel) -> Vec { + let mut v = words(&[2]); + levels_write(&mut v, master); + for c in per_channel { + levels_write(&mut v, c); + } + let ident = LevelsChannel::default(); + levels_write(&mut v, if space == ToneSpace::Cmyk { black } else { &ident }); + for _ in 5..29 { + levels_write(&mut v, &LevelsChannel::default()); + } + v +} + +/// Curves payload: each channel's points, or its 256-entry map in pencil mode. +pub(super) fn curves(master: &[CurvePoint], per_channel: &[Vec; 3], space: ToneSpace, black: &[CurvePoint], pencil: bool) -> Vec { + let mut v = vec![u8::from(pencil)]; + put16(&mut v, 1); + let ink = space == ToneSpace::Cmyk && black.len() >= 2; + v.extend_from_slice(&(if ink { 0b11111u32 } else { 0b1111 }).to_be_bytes()); + for c in std::iter::once(master).chain(per_channel.iter().map(Vec::as_slice)).chain(ink.then_some(black)) { + if pencil { + v.extend(curve_map_outputs(c).iter().map(|&o| u8::try_from(q255(o)).unwrap_or(u8::MAX))); + continue; + } + put16(&mut v, c.len().min(19) as u16); + for p in c.iter().take(19) { + put16(&mut v, q255(p.output)); + put16(&mut v, q255(p.input)); + } + } + v +} + +/// Selective Color payload, with each value clamped to ±100%. +pub(super) fn selective_color(relative: bool, adjustments: &[[f32; 4]; 9]) -> Vec { + let mut v = words(&[1, u16::from(!relative)]); + v.extend_from_slice(&[0; 8]); + for rec in adjustments { + for x in rec { + push_i16(&mut v, x.clamp(-100.0, 100.0)); + } + } + v +} + +/// Color Lookup payload, written as an identity cube when there is no usable table. +pub(super) fn color_lookup(name: &str, lut: Option<&[f32]>, size: u32, dither: bool, source: Option<&LutSource>) -> Vec { + // A lookup without a usable table (none chosen yet, or a bad size) renders as the + // identity, so it is written as a 2³ identity cube rather than dropped. + let n = size as usize; + let cube = lut.zip(photocraft_doc::adjust::lut3d_len(size)).and_then(|(table, len)| table.get(..len)).filter(|_| n <= 256); + // Like Photoshop, embed the LUT file as loaded, in its own format; a table made or + // changed in PhotoCraft is written as `.cube`, named to match. + let (format, data, name, dither) = match cube { + Some(cube) => match lut_source_for(source, cube, n) { + Some(src) => (src.format, src.bytes.to_vec(), name.to_string(), dither), + None => { + let file = photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] }; + (LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), dither) + } + }, + _ => { + let file = photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }; + (LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), false) + } + }; + let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) }; + let text = |t: &str| Value::Text(UnicodeString::new_nul(t)); + let d = Descriptor::new("null") + .with("lookupType", en("colorLookupType", "3DLUT")) + .with("NM ", text(&name)) + .with("Dthr", Value::Boolean(dither)) + .with("profile", Value::RawData(Vec::new())) + .with("LUTFormat", en("LUTFormatType", psd_lut_format(format))) + .with("dataOrder", en("colorLookupOrder", "rgbOrder")) + .with("tableOrder", en("colorLookupOrder", "bgrOrder")) + .with("LUT3DFileData", Value::RawData(data)) + .with("LUT3DFileName", text(&name)); + let mut v = words(&[1]); + v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); + v +} + +/// At least the two stops Photoshop needs, matching how fewer stops render. +pub(super) fn gradient_stops(stops: &[(f32, [f32; 3])]) -> Vec<(f32, [f32; 3])> { + // Photoshop needs two stops: none becomes black to white, one becomes a flat colour. + match stops { + [] => vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], + [(_, c)] => vec![(0.0, *c), (1.0, *c)], + s => s.to_vec(), + } +} + +/// A descriptor integer of `x` clamped to `lo..=hi`. +fn percent(x: f32, lo: f32, hi: f32) -> Value { + Value::Integer(i32::from(clamp_i16(x.clamp(lo, hi)))) +} + +/// Vibrance payload. +pub(super) fn vibrance(vibrance: f32, saturation: f32) -> Vec { + let d = Descriptor::new("null").with("vibrance", percent(vibrance, -100.0, 100.0)).with("Strt", percent(saturation, -100.0, 100.0)); + VersionedDescriptor::new(d).to_bytes() +} + +/// Color Balance payload: shadows, midtones and highlights, then Preserve Luminosity. +pub(super) fn color_balance(tones: [&[f32; 3]; 3], preserve_luminosity: bool) -> Vec { + let mut v = Vec::new(); + for x in tones.into_iter().flatten() { + push_i16(&mut v, x.clamp(-100.0, 100.0)); + } + v.push(u8::from(preserve_luminosity)); + v.push(0); + v +} + +/// Black & White payload. +pub(super) fn black_white(weights: &[f32; 6], tint: Option<[f32; 3]>) -> Vec { + let mut d = Descriptor::new("null"); + for (w, k) in weights.iter().zip(BW_KEYS) { + d = d.with(k, percent(*w, -200.0, 300.0)); + } + let d = d + .with("useTint", Value::Boolean(tint.is_some())) + .with("tintColor", Value::Descriptor(rgb_desc(tint.unwrap_or(BW_TINT)))) + .with("bwPresetKind", Value::Integer(1)) + .with("blackAndWhitePresetFileName", Value::Text(UnicodeString::new_nul(""))); + VersionedDescriptor::new(d).to_bytes() +} + +/// Photo Filter payload, version 2 with a 16-bit colour. +pub(super) fn photo_filter(color: [f32; 3], density: f32, preserve_luminosity: bool) -> Vec { + // Version 2 keeps the colour at 16 bits. + let mut v = words(&[2, 0]); + for x in color { + put16(&mut v, (x.clamp(0.0, 1.0) * 65535.0).round() as u16); + } + put16(&mut v, 0); + v.extend_from_slice(&((density.clamp(0.0, 1.0) * 100.0).round() as u32).to_be_bytes()); + v.push(u8::from(preserve_luminosity)); + // Photoshop only opens the payload padded to 4 bytes inside the block (#1455). + v.extend_from_slice(&[0, 0, 0]); + v +} + +/// Channel Mixer payload: the red, green and blue records, then the gray record. +pub(super) fn channel_mixer(matrix: &[[f32; 4]; 3], monochrome: bool) -> Vec { + let mut v = words(&[1, u16::from(monochrome)]); + // Red, green and blue rows, then the gray record that monochrome uses. + for row in matrix.iter().chain(std::iter::once(&matrix[0])) { + let pct = |x: f32| clamp_i16((x * 100.0).clamp(-200.0, 200.0)).to_be_bytes(); + for x in &row[..3] { + v.extend_from_slice(&pct(*x)); + } + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&pct(row[3])); + } + v +} diff --git a/crates/io/src/adjust_map/tests.rs b/crates/io/src/adjust_map/tests.rs index 70727fba4f..c369327d86 100644 --- a/crates/io/src/adjust_map/tests.rs +++ b/crates/io/src/adjust_map/tests.rs @@ -219,7 +219,9 @@ fn roundtrips() { rt(Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }); rt(Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0, 1.0, 0.0, 0.0], [-2.0, 0.0, 2.0, 0.0]], monochrome: true }); let id = photocraft_cms::lutfile::LutFile::identity(5); - rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true }); + let cube = photocraft_cms::lutfile::write_cube(&id).into_bytes(); + let source = Some(LutSource { format: LutFormat::Cube, bytes: std::sync::Arc::new(cube) }); + rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true, source }); } #[test] @@ -246,6 +248,105 @@ fn selective_color_synthetic_block() { assert_eq!(write(&a)[0].1, d); } +/// A 2³ Lustre `.3dl` (12-bit, blue fastest) that inverts every colour. +fn inverting_3dl() -> Vec { + let mut text = String::from("# made by hand\n0 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + text.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095)); + } + } + } + text.into_bytes() +} + +/// A 2³ SpeedGrade `.look` holding the identity. +fn identity_look() -> Vec { + let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + format!("\n\"2\"\"{hex}\"\n").into_bytes() +} + +/// A `clrL` block as Photoshop writes one for a loaded LUT file. +fn clrl_block(format: &str, bytes: &[u8], name: &str) -> Vec { + let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); + let desc = Descriptor::new("null") + .with("lookupType", en("colorLookupType", "3DLUT")) + .with("NM ", text(name)) + .with("LUTFormat", en("LUTFormatType", format)) + .with("LUT3DFileData", Value::RawData(bytes.to_vec())) + .with("LUT3DFileName", text(name)); + let mut d = vec![0, 1]; + d.extend(VersionedDescriptor::new(desc).to_bytes()); + d +} + +/// The descriptor of the `clrL` block `a` is written as. +fn written_clrl(a: &Adjustment) -> Descriptor { + let b = write(a); + assert_eq!(&b[0].0, b"clrL"); + VersionedDescriptor::parse_prefix(&b[0].1[2..]).unwrap().0.descriptor +} + +fn raw<'a>(d: &'a Descriptor, key: &str) -> &'a [u8] { + let Some(Value::RawData(b)) = d.get(key) else { panic!("{key} missing") }; + b +} + +#[test] +fn color_lookup_keeps_a_3dl_or_look_file_as_loaded() { + // #6544: Photoshop embeds the LUT file as loaded and tags its format; it never converts it. + for (format, kind, bytes, name) in + [("LUTFormat3DL", LutFormat::ThreeDl, inverting_3dl(), "Invert.3DL"), ("LUTFormatLOOK", LutFormat::Look, identity_look(), "Grade.look")] + { + let a = parse(b"clrL", &clrl_block(format, &bytes, name), None, Channels::Rgb); + let Adjustment::ColorLookup { name: n, lut: Some(_), size: 2, source: Some(src), .. } = &a else { panic!("{a:?}") }; + assert_eq!((n.as_str(), src.format, src.bytes.as_slice()), (name, kind, bytes.as_slice())); + let d = written_clrl(&a); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(format.as_bytes()), "{name}"); + assert_eq!(raw(&d, "LUT3DFileData"), bytes.as_slice(), "{name}: the file's bytes, unchanged"); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some(name)); + assert_eq!(desc_text(&d, "NM ").as_deref(), Some(name)); + // And it reads back as the same layer. + let b = write(&a); + assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Rgb), a); + } +} + +#[test] +fn color_lookup_table_without_matching_file_is_saved_as_cube_named_to_match() { + let a = parse(b"clrL", &clrl_block("LUTFormat3DL", &inverting_3dl(), "Invert.3DL"), None, Channels::Rgb); + let Adjustment::ColorLookup { name, size, tetrahedral, dither, source, .. } = a else { panic!("{a:?}") }; + // The table no longer matches the stored file (changed in PhotoCraft): the bytes are stale. + let id = photocraft_cms::lutfile::LutFile::identity(2).data; + let edited = Adjustment::ColorLookup { name, lut: Some(std::sync::Arc::new(id.clone())), size, tetrahedral, dither, source }; + let d = written_clrl(&edited); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..])); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Invert.cube")); + assert_eq!(photocraft_cms::lutfile::parse("x.cube", raw(&d, "LUT3DFileData")).unwrap().data, id); + // A table made in PhotoCraft (no file) named like another format is also renamed. + let made = + Adjustment::ColorLookup { name: "Grade.look".into(), lut: Some(std::sync::Arc::new(id)), size: 2, tetrahedral: false, dither: false, source: None }; + let d = written_clrl(&made); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..])); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Grade.cube")); + assert_eq!(desc_text(&d, "NM ").as_deref(), Some("Grade.cube")); + // An unreadable stored file falls back the same way instead of failing the save. + let junk = Adjustment::ColorLookup { + name: "Junk.3dl".into(), + lut: Some(std::sync::Arc::new(photocraft_cms::lutfile::LutFile::identity(2).data)), + size: 2, + tetrahedral: false, + dither: false, + source: Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"\xFF not a lut".to_vec()) }), + }; + assert_eq!(desc_text(&written_clrl(&junk), "LUT3DFileName").as_deref(), Some("Junk.cube")); + assert_eq!(cube_file_name("a.b.3dl"), "a.b.cube"); + assert_eq!(cube_file_name("Day for Night"), "Day for Night"); + assert_eq!(cube_file_name("x.cube"), "x.cube"); +} + #[test] fn color_lookup_synthetic_block() { let cube = b"TITLE \"t\"\nLUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; @@ -324,25 +425,43 @@ fn color_lookup_default_domains_stay_supported() { #[test] fn malformed_falls_back_to_unsupported() { - assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"curv", &[0, 0, 9], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"thrs", &[], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"blnc", &[0; 18], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"vibA", &[0, 0, 0, 15, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"blwh", &[], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"mixr", &[0, 2, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); - // A CMYK mixer (black source used) stays raw in CMYK documents; our own RGB layout maps. + malformed_adjustments_are_unsupported(); + malformed_channel_mixers_are_unsupported(); + malformed_photo_filters_are_unsupported(); + arbitrary_adjustment_bytes_do_not_panic(); +} + +fn malformed_adjustments_are_unsupported() { + for (key, bytes) in [ + (&b"levl"[..], &[0, 2, 1][..]), + (&b"curv"[..], &[0, 0, 9][..]), + (&b"thrs"[..], &[][..]), + (&b"blnc"[..], &[0; 18][..]), + (&b"vibA"[..], &[0, 0, 0, 15, 0][..]), + (&b"blwh"[..], &[][..]), + (&b"mixr"[..], &[0, 2, 0, 0][..]), + ] { + let key: &[u8; 4] = key.try_into().unwrap_or(b"????"); + assert!(matches!(parse(key, bytes, None, Channels::Rgb), Adjustment::Unsupported { .. }), "{key:?}"); + } +} + +fn malformed_channel_mixers_are_unsupported() { let mut cmyk = i16s(&[1, 0]); for r in 0..4 { cmyk.extend(i16s(&std::array::from_fn::(|k| if k == r { 100 } else { 0 }))); } - assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Cmyk), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Other), Adjustment::Unsupported { .. })); + for channels in [Channels::Cmyk, Channels::Other] { + assert!(matches!(parse(b"mixr", &cmyk, None, channels), Adjustment::Unsupported { .. })); + } let ours = write(&Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false }); - assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Cmyk), Adjustment::ChannelMixer { .. })); - assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Other), Adjustment::ChannelMixer { .. })); + for channels in [Channels::Cmyk, Channels::Other] { + assert!(matches!(parse(b"mixr", &ours[0].1, None, channels), Adjustment::ChannelMixer { .. })); + } assert!(matches!(parse(b"mixr", &[0, 1, 0, 0, 0, 100], None, Channels::Rgb), Adjustment::Unsupported { .. })); - // Photo Filter: unknown version, HSB colour space, out-of-range version-3 XYZ, truncated. +} + +fn malformed_photo_filters_are_unsupported() { assert!(matches!(parse(b"phfl", &[0, 4, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"phfl", &[0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 25, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); for bad in [[200_000u32, 0, 0], [0, u32::MAX, 0]] { @@ -354,12 +473,14 @@ fn malformed_falls_back_to_unsupported() { assert!(matches!(parse(b"phfl", &xyz, None, Channels::Rgb), Adjustment::Unsupported { .. })); } assert!(matches!(parse(b"phfl", &[0, 2, 0, 0, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); - // Arbitrary bytes never panic, whatever the key. +} + +fn arbitrary_adjustment_bytes_do_not_panic() { for key in ADJUSTMENT_KEYS { for len in 0..64usize { - let d: Vec = (0..len).map(|i| (i * 37 + len) as u8).collect(); - let _ = parse(key, &d, Some(&d), Channels::Rgb); - let _ = parse(key, &d, None, Channels::Other); + let data: Vec = (0..len).map(|i| (i * 37 + len) as u8).collect(); + let _ = parse(key, &data, Some(&data), Channels::Rgb); + let _ = parse(key, &data, None, Channels::Other); } } } @@ -547,7 +668,7 @@ fn degenerate_adjustments_still_get_an_encoding() { } // A colour lookup without a (usable) table becomes an identity cube, without dither. for lut in [None, Some(std::sync::Arc::new(vec![0.5; 7]))] { - let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true }); + let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true, source: None }); let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb); let Adjustment::ColorLookup { lut: Some(t), size: 2, dither: false, name, .. } = back else { panic!("{back:?}") }; assert_eq!(name, "L"); diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index f794e1be40..4e5ed90f86 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -149,7 +149,7 @@ impl Builder { let r = self.bbox(to_doc, page); let mut g = Layer::new( format!("Page {}", i + 1), - LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }), + LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None, frame_stroke: None }), ); if let LayerContent::Group(gr) = &mut g.content { gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() }); @@ -244,7 +244,7 @@ impl Builder { fn group(&mut self, n: &af::Node, children: Vec) -> Layer { // Pass-through and Normal (isolated) groups map one to one. - self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None })) + self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None })) } /// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas. diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index a2e63322d0..65df97e898 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -228,7 +228,7 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result let text = std::str::from_utf8(bytes).map_err(|_| "not a Camera Raw settings file (not UTF-8 text)".to_string())?; // Some writers put a byte order mark or an `` wrapper first; roxmltree takes both. let text = text.trim_start_matches('\u{feff}'); - let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a Camera Raw settings file ({e})"))?; + let doc = crate::xml::parse(text, roxmltree::ParsingOptions::default()).map_err(|e| format!("not a Camera Raw settings file ({e})"))?; let mut next = params.clone(); let mut report = LoadReport::default(); let mut found = false; @@ -389,6 +389,16 @@ mod tests { assert_eq!(p.calibration_hue[0], -5.0); } + /// A settings file nested 20 000 deep overflowed the XML parser's recursion (an abort nothing + /// can catch); it is an error now. The small stack makes a regression abort instead of passing. + #[test] + fn deeply_nested_settings_are_an_error_not_a_stack_overflow() { + let xml = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let e = + std::thread::Builder::new().stack_size(512 * 1024).spawn(move || load(&mut CameraRaw::default(), xml.as_bytes()).err()).unwrap().join().unwrap(); + assert!(e.as_deref().is_some_and(|m| m.contains("nested")), "{e:?}"); + } + #[test] fn save_then_load_round_trips() { let mut p = CameraRaw { diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index af9b238971..d8b3f9a36d 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -283,10 +283,10 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result Result { let v = number(image, a, 0.0)?; diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs index 92e951cdb8..214ccdc9c6 100644 --- a/crates/io/src/kra/tests.rs +++ b/crates/io/src/kra/tests.rs @@ -200,6 +200,23 @@ fn export_is_refused() { } } +/// maindoc.xml nested 20 000 deep, directly or through an internal-subset entity the depth check +/// can't see, overflowed the XML parser's recursion (an abort nothing can catch). Both are errors +/// now; the small stack makes a regression abort instead of passing. +#[test] +fn deeply_nested_maindoc_is_an_error_not_a_stack_overflow() { + let (open, close) = ("".repeat(20_000), "".repeat(20_000)); + let direct = maindoc("RGBA", &format!("{open}{close}")); + let entity = format!("]>&e;"); + // A quoted `?>` in the XML declaration must not hide the DOCTYPE behind it. + let declared = format!("\"?>{entity}"); + for (xml, why) in [(direct, "nested"), (entity, "internal DTD"), (declared, "internal DTD")] { + let bytes = archive(&xml, &[]); + let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || crate::import("deep.kra", &bytes).err()).unwrap().join().unwrap(); + assert!(matches!(&e, Some(IoError::Kra(m)) if m.contains(why)), "{why}: {e:?}"); + } +} + #[test] fn lzf_decodes_literals_and_overlapping_references() { // "abc" then a 6-byte back-reference 3 back: abcabcabc. diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 44beb59d47..0a184bda79 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -75,6 +75,7 @@ pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; mod xcf; +pub mod xml; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; @@ -125,6 +126,9 @@ pub enum IoError { /// An SVG that does not parse (or is too large to rasterise). #[error("SVG: {0}")] Svg(String), + /// The file could not be read. + #[error("{0}")] + Read(String), /// A background import or save was cancelled ([`import_with`], [`export_with`]). #[error("cancelled")] Cancelled, @@ -373,26 +377,82 @@ pub fn import_for_open_with(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: Ok((result, damaged)) } -fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, damaged: &mut bool) -> Result { +/// The PSD/PSB part of an import, from a parse that takes [`PsdFile::from_bytes_and_merged_recovering`]'s +/// `want` and `recover`: of the file's bytes, or of the file itself ([`import_file_for_open_with`]). +fn import_psd( + name: &str, + ctl: &photocraft_raster::Interrupt, + damaged: &mut bool, + parse: impl FnOnce(&dyn Fn(&PsdFile) -> bool, &dyn Fn(&PsdFile) -> bool) -> photocraft_psd::Result<(PsdFile, Option>>)>, +) -> Result { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. - let (file, merged) = PsdFile::from_bytes_and_merged_recovering( - bytes, - |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), - psd_import::can_recover_layers, - )?; + // A failed read of the file (one parsed from its source) is a read error, as reading the + // whole file first would report it. + let (file, merged) = + parse(&|f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), &psd_import::can_recover_layers).map_err(|e| match e { + photocraft_psd::PsdError::Read(msg) => IoError::Read(msg), + e => e.into(), + })?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings, psd_damaged) = psd_import::psd_to_document_decoded_integrity(&file, ctl, merged).ok_or(IoError::Cancelled)?; + // Read before the conversion, which moves the global blocks into the document. + let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); + let (mut document, warnings, psd_damaged, read_error) = psd_import::psd_to_document_owned(file, ctl, merged).ok_or(IoError::Cancelled)?; + // The file became unreadable while its layers were read from it (`import_file_for_open_with`): + // the open fails, rather than opening with those layers empty. + if let Some(e) = read_error { + return Err(IoError::Read(e.to_string())); + } *damaged = psd_damaged; document.name = name.to_string(); - let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); text_import::prepare(&mut document, txt2.as_ref()); Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) } +/// [`import_for_open_with`] for the file at `path`. A PSD/PSB is parsed from the open file +/// ([`PsdFile::from_source`]) rather than read whole: its channel data is read layer by layer as +/// it is decoded, so opening it needs memory for the document, not for the document plus the +/// file. A read that fails meanwhile (the file is truncated or rewritten, its drive goes away) +/// fails the open. Other formats are read whole. +#[cfg(any(unix, windows))] +pub fn import_file_for_open_with( + name: &str, + path: &std::path::Path, + opts: &ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result<(ImportResult, bool), IoError> { + let read_failed = |e: std::io::Error| IoError::Read(format!("{}: {e}", path.display())); + ctl.check().map_err(|_| IoError::Cancelled)?; + let source = photocraft_psd::FileSource::open(path).map_err(read_failed)?; + let mut magic = [0u8; 4]; + let psd = !has_extension(name, photocraft_format::EXTENSION) && photocraft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); + if !psd { + drop(source); + let bytes = photocraft_format::read_file(path).map_err(read_failed)?; + return import_for_open_with(name, &bytes, opts, ctl); + } + import_source_for_open_with(name, photocraft_psd::Source::new(source), ctl) +} + +/// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. +pub fn import_source_for_open_with(name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut damaged = false; + let r = import_psd(name, ctl, &mut damaged, |want, recover| PsdFile::from_source_and_merged_recovering(source, want, recover))?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok((r, damaged)) +} + +/// [`import_file_for_open_with`] without the damage flag. +#[cfg(any(unix, windows))] +pub fn import_file(name: &str, path: &std::path::Path, opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + import_file_for_open_with(name, path, opts, ctl).map(|(result, _)| result) +} + /// 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 { @@ -404,7 +464,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, bytes, ctl, damaged); + return import_psd(name, ctl, damaged, |want, recover| PsdFile::from_bytes_and_merged_recovering(bytes, want, recover)); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -437,7 +497,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, bytes, ctl, damaged); + return import_psd(name, ctl, damaged, |want, recover| PsdFile::from_bytes_and_merged_recovering(bytes, want, recover)); } flat::import_flat(name, bytes) } diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index e200b875ea..dcf7ecd98d 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -118,7 +118,30 @@ pub fn find_in_blocks<'a>(blocks: impl IntoIterator(blocks: impl IntoIterator, uuid: &str) -> Option<(String, String, &'a [u8])> { + if uuid.is_empty() { + return None; + } + blocks.into_iter().filter(|(k, _)| matches!(*k, b"lnk2" | b"lnk3" | b"lnkD")).find_map(|(_, d)| { + let mut r = Rd { d, p: 0 }; + while let Some(len) = r.u64() { + let Ok(len) = usize::try_from(len) else { return None }; + let item = r.take(len)?; + let found = item_header(item).filter(|h| h.0 == uuid).and_then(|(id, name, start, n)| Some((id, name, item.get(start..start.checked_add(n)?)?))); + if found.is_some() { + return found; + } + r.p = r.p.next_multiple_of(4); + } + None + }) } /// The size of embedded file `uuid` among PSD global blocks given as (key, data), when it is there @@ -128,11 +151,11 @@ pub(crate) fn source_size_in_blocks<'a>( uuid: &str, ctl: &photocraft_raster::Interrupt, ) -> Option<(f64, f64)> { - let f = find_in_blocks(blocks, uuid)?; + let (_, _, bytes) = find_slice_in_blocks(blocks, uuid)?; if ctl.cancelled() { return None; } - source_size_probe(&f.bytes).filter(|(w, h)| *w > 0.0 && *h > 0.0) + source_size_probe(bytes).filter(|(w, h)| *w > 0.0 && *h > 0.0) } /// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index e9437deb6c..2b6f4b9b54 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -73,7 +73,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St } /// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane). -pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec) { +/// A file past `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps +/// the version whatever the size. +pub(crate) fn document_to_psd(doc: &Document, force_psb: bool, size_limit: Option) -> (PsdFile, Vec) { let mut warnings = Vec::new(); // Photoshop's Multichannel mode is 8 or 16 bits per channel. let sample = match doc.depth { @@ -106,17 +108,14 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< } } let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc); - let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit); + let version = crate::psd_export::psd_version(doc, force_psb); if big && !force_psb { warnings.push("document exceeds 30000 px; written as PSB".into()); - } else if size_exceeds_limit && !force_psb { - warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into()); } let header = Header::new(version, doc.size.width, doc.size.height, channels.len().max(1) as u16, psd_depth(sample), PsdMode::Multichannel); let image_data = ImageData::encode(Compression::Rle, &planes, &header) .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); + .unwrap_or(ImageData::new(Compression::Raw, planes)); let mut resources = vec![ ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), @@ -133,14 +132,16 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< // Alpha identifiers: one u32 per channel, only while they still match the channels. let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4); let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); - let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, size_limit, &mut warnings); (file, warnings) } /// A PSD without layers around `image_data`: `resources` (the mode's own) plus the paths, XMP, /// EXIF and every other imported resource `keep` accepts (except the ones that only describe /// layers and any already in `resources`), and the document-level tagged blocks. Shared by the -/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap. +/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap. A file past +/// `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps the version. +#[allow(clippy::too_many_arguments)] pub(crate) fn layerless_file( doc: &Document, header: Header, @@ -148,6 +149,7 @@ pub(crate) fn layerless_file( color_mode_data: Vec, mut resources: Vec, keep: impl Fn(u16, &[u8]) -> bool, + size_limit: Option, warnings: &mut Vec, ) -> PsdFile { resources.extend(crate::psd_export::path_resources(doc, warnings)); @@ -175,13 +177,14 @@ pub(crate) fn layerless_file( } resources.push(version_info_resource(true)); // Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a - // kept layer block would describe layers the document doesn't have. + // kept layer block would describe layers the document doesn't have. Their signatures follow + // the version the file is written as, known once its size is. let global_blocks: Vec = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)) .into_iter() .filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32")) - .map(|(sig, key, data)| crate::psd_export::global_block(crate::psd_export::block_signature(sig, key, header.version), key, &data)) + .map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data)) .collect(); - PsdFile { + let mut file = PsdFile { header, color_mode_data, resources, @@ -191,5 +194,13 @@ pub(crate) fn layerless_file( global_blocks, layer_mask_trailing: Vec::new(), image_data, + write_as_psb: false, + }; + if let Some(limit) = size_limit + && let Some(warning) = crate::psd_export::fit_psd_size(&mut file, limit) + { + warnings.push(warning); } + crate::psd_export::sign_global_blocks(&mut file); + file } diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 193680d31e..fb7968fbcc 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -414,7 +414,7 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result root")); diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index c5576ce0ef..e4797fc52d 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -195,6 +195,16 @@ fn archive(stack: &str, images: &[(&str, Vec)]) -> Vec { z.finish().unwrap() } +/// A stack.xml nested 20 000 deep overflowed the XML parser's recursion (an abort nothing can +/// catch); it is an error now. The small stack makes a regression abort instead of passing. +#[test] +fn deeply_nested_stack_xml_is_an_error_not_a_stack_overflow() { + let stack = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let bytes = archive(&stack, &[]); + let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || crate::import("deep.ora", &bytes).err()).unwrap().join().unwrap(); + assert!(matches!(&e, Some(IoError::Ora(m)) if m.contains("nested")), "{e:?}"); +} + fn solid_png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { let data = rgba.iter().copied().cycle().take((w * h * 4) as usize).collect(); codecs::encode(&Image::from_raw(w, h, ChannelLayout::Rgba, CSample::U8, data).unwrap(), Format::Png, &Default::default()).unwrap() diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs index 45b8168f1c..e1729dcce0 100644 --- a/crates/io/src/palette_map.rs +++ b/crates/io/src/palette_map.rs @@ -21,7 +21,9 @@ const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPL /// opaque, or a Bitmap document; None for any other document (it is written like the others). A /// document with alpha channels or a Quick Mask is written like the others too: the importer does /// not read the extra channels of these modes back, so they would be lost. -pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec)> { +/// A file past `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps +/// the version whatever the size. +pub(crate) fn document_to_psd(doc: &Document, force_psb: bool, size_limit: Option) -> Option<(PsdFile, Vec)> { if !doc.channels.is_empty() || doc.quick_mask.is_some() { return None; } @@ -38,14 +40,12 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil if !flattens_losslessly(doc) { warnings.push(format!("{:?} documents have no layers in Photoshop: written flattened", doc.mode)); } - let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc); - let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit); + let version = crate::psd_export::psd_version(doc, force_psb); let depth = if mode == PsdMode::Bitmap { 1 } else { 8 }; let header = Header::new(version, doc.size.width, doc.size.height, 1, depth, mode); let image_data = ImageData::encode(Compression::Rle, &plane, &header) .or_else(|_| ImageData::encode(Compression::Raw, &plane, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: plane }); + .unwrap_or(ImageData::new(Compression::Raw, plane)); let mut resources = vec![ ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), @@ -65,7 +65,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil } // An imported transparency index that no longer applies is not copied either. let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT; - let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, size_limit, &mut warnings); Some((file, warnings)) } @@ -162,7 +162,7 @@ mod tests { } fn round_trip(doc: &Document) -> (PsdFile, Document) { - let (file, _) = document_to_psd(doc, false).unwrap(); + let (file, _) = document_to_psd(doc, false, None).unwrap(); let bytes = file.to_bytes().unwrap(); let file = PsdFile::from_bytes(&bytes).unwrap(); let back = crate::import("test.psd", &bytes).unwrap().document; @@ -214,17 +214,17 @@ mod tests { assert_eq!(back.color_table, doc.color_table); for count in [0, 257] { doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0]; count], transparent: None }); - assert!(document_to_psd(&doc, false).is_none(), "{count}"); + assert!(document_to_psd(&doc, false, None).is_none(), "{count}"); } doc.color_table = None; - assert!(document_to_psd(&doc, false).is_none()); + assert!(document_to_psd(&doc, false, None).is_none()); } #[test] fn indexed_import_bounds_malformed_count_and_transparency_resources() { let mut doc = document(1, 1, SampleType::U8, ColorMode::Indexed, &[0.0, 0.0, 0.0, 1.0]); doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None }); - let (original, _) = document_to_psd(&doc, false).unwrap(); + let (original, _) = document_to_psd(&doc, false, None).unwrap(); for (count, expected) in [(vec![0, 0], 1), (vec![1, 1], 256), (vec![255, 255], 256), (vec![0], 256)] { let mut file = original.clone(); file.resources.retain(|r| r.id != COLOR_TABLE_COUNT); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 4f992bc8fd..92a5a05850 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -59,8 +59,10 @@ fn pdf_string(s: &str) -> String { } fn deflate(data: &[u8]) -> Vec { - // Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default. - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + // 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 + // than miniz's level 1 was, at about twice its time. + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(3)); let _ = e.write_all(data); e.finish().unwrap_or_default() } diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 15e648df1a..9dff11fcce 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -93,11 +93,30 @@ pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> let bps = s.bytes(); let n = bytes.len() / (ch * bps).max(1); if s == SampleType::U8 { - // Fast path: one plane per thread, no per-sample copies. - return par_map((0..ch).collect(), |c| { - let it = bytes.iter().skip(c).step_by(ch).take(n); - if invert[c] { it.map(|v| 255 - v).collect() } else { it.copied().collect() } + // Fast path: one band of pixels per core, written straight into every plane. + let mut planes = vec![vec![0u8; n]; ch]; + let bs = bands(n); + let mut slots: Vec> = bs.iter().map(|_| Vec::with_capacity(ch)).collect(); + for plane in &mut planes { + let mut rest = plane.as_mut_slice(); + for (slot, band) in slots.iter_mut().zip(&bs) { + let Some((part, tail)) = rest.split_at_mut_checked(band.len()) else { break }; + slot.push(part); + rest = tail; + } + } + par_map(bs.into_iter().zip(slots).collect(), |(band, mut outs)| { + let src = bytes.get(band.start * ch..band.end * ch).unwrap_or(&[]); + for (c, out) in outs.iter_mut().enumerate() { + let px = src.chunks_exact(ch).filter_map(|p| p.get(c).copied()); + if invert.get(c).copied().unwrap_or(false) { + out.iter_mut().zip(px).for_each(|(o, v)| *o = 255 - v); + } else { + out.iter_mut().zip(px).for_each(|(o, v)| *o = v); + } + } }); + return planes; } // One band of rows per core, appended in band order into pre-grown planes. let row = ch * bps; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 0c809273a9..c6f8539106 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -22,13 +22,11 @@ mod resources; mod smart; pub(crate) use document::document_to_psd_interruptible; +#[cfg(test)] +use document::document_to_psd_sized; pub use document::{document_to_psd, document_to_psd_with}; use document::{document_to_psd_nested, mask_parameters}; -#[cfg(test)] -pub(crate) use estimate::estimate_psd_size; -pub(crate) use estimate::{block_signature, psd_size_exceeds_limit, psd_version}; -#[cfg(test)] -use estimate::{estimate_encoded_size, exceeds_psd_size_limit}; +pub(crate) use estimate::{fit_psd_size, psd_version, sign_global_blocks}; pub(crate) use resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource}; use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, masked, size_from_layer, smart_keys, upsert}; @@ -37,24 +35,25 @@ use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, maske /// A raster layer never writes them, nor adjustment blocks. const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"]; +/// The largest file written as PSD: past it, the document is saved as PSB (Large Document Format), +/// as Photoshop does. const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; +/// Room kept under [`PSD_MAX_FILE_SIZE`] for what the save adds after the conversion (the image +/// preview resource, a few KB). +const PSD_SAVE_HEADROOM: u64 = 1024 * 1024; /// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and /// the channel planes made from them (or the band parts and the joined buffer) are alive together, /// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`]. const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 }; -const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024; -const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; /// Options for [`document_to_psd_with`]. #[derive(Debug, Clone)] pub struct PsdExportOptions { /// Write PSB even when the document fits PSD limits. pub force_psb: bool, - /// Switch to PSB when the estimated encoded size exceeds PSD's 2 GB limit. The estimate - /// counts every layer plane uncompressed, so a document with many canvas-sized layers can - /// pass it while its real file is far smaller. A writer that can measure the real size - /// instead turns this off: the layered TIFF writes PSD-style layer data and falls back to - /// PSB only when a length really overflows 32 bits. + /// Switch to PSB when the encoded file exceeds PSD's 2 GB limit (`fit_psd_size`). A writer + /// that embeds the layer data elsewhere turns this off: the layered TIFF writes PSD-style + /// layer data and falls back to PSB only when a length really overflows 32 bits. pub size_limit_psb: bool, /// Matte the merged image against white where it is translucent, as Photoshop does /// (`false` keeps straight colour under the alpha: for containers that store the composite @@ -189,7 +188,7 @@ impl Ex { let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() }; ChannelData::encode(id, compression, plane, w, h, depth, self.version) .or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version)) - .unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() }) + .unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec())) } fn empty_channels(&self) -> Vec { @@ -543,7 +542,23 @@ impl Ex { let masked = l.vector_mask.is_some(); raw.retain(|(k, _)| !(RASTER_STALE_KEYS.contains(&k) || adjust_map::ADJUSTMENT_KEYS.contains(&k) || k == b"CgEd" || (k == b"vstk" && masked))); } - LayerContent::Group(_) => {} + // A Frame's stroke (`vstk`), kept byte-identical while it still decodes to the model. + LayerContent::Group(g) => { + let pos = raw.iter().position(|(k, _)| k == b"vstk"); + let old = pos.and_then(|i| raw.get(i)).and_then(|(_, d)| crate::vector_map::parse_vstk(d, self.dpi)); + // One we can't decode stays as it was unless the model has a stroke to write. + let stale = match &old { + Some(v) => v.stroke != g.frame_stroke, + None => g.frame_stroke.is_some(), + }; + if stale { + let data = crate::vector_map::vstk_bytes(g.frame_stroke.as_ref(), old.is_none_or(|v| v.fill_enabled), self.dpi); + match pos.and_then(|i| raw.get_mut(i)) { + Some(e) => e.1 = data, + None => raw.push((*b"vstk", data)), + } + } + } } // Effects reference point: written from the field (in place, keeping block order). match l.effects.reference { diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index 0ad7188a58..c9e68fd028 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -1,8 +1,13 @@ -use super::estimate::{MERGED_RENDERED, MERGED_WEIGHT, block_signature, psd_size_exceeds_limit, psd_version}; +use super::PSD_MAX_FILE_SIZE; +use super::estimate::{MERGED_RENDERED, MERGED_WEIGHT, fit_psd_size, psd_version, sign_global_blocks}; use super::merged::{cmyk_of, merged_planes, placeholder_planes}; use super::resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource}; use super::smart::{SmartOut, link_group_resource}; -use super::*; +use super::{ + ColorMode, Compression, Document, Ex, GlobalLayerMask, Header, ImageData, ImageResource, Interrupt, LayerContent, LayerInfo, LayerInfoPlacement, + MaskParameters, PixelFormat, PsdExportOptions, PsdFile, PsdMode, ResolutionInfo, SampleType, TaggedBlock, deinterleave, ids, psd_background, psd_depth, + psd_mode, q255, version_info_resource, +}; /// Converts a document to a PSD file model (see [`document_to_psd_with`]). pub fn document_to_psd(doc: &Document) -> PsdFile { @@ -13,25 +18,42 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, &Interrupt::NONE) + document_to_psd_sized(doc, opts, 0, &Interrupt::NONE, PSD_MAX_FILE_SIZE) } /// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after /// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops /// early and returns an **incomplete** model: callers check `ctl` and discard it. pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, ctl) + document_to_psd_sized(doc, opts, 0, ctl, PSD_MAX_FILE_SIZE) } /// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec) { + document_to_psd_sized(doc, opts, depth, ctl, PSD_MAX_FILE_SIZE) +} + +/// Export with a caller-selected PSD size limit; the test suite lowers it to exercise promotion. +pub(super) fn document_to_psd_sized(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt, size_limit: u64) -> (PsdFile, Vec) { + let layerless_limit = opts.size_limit_psb.then_some(size_limit); if doc.mode == ColorMode::Multichannel { - return crate::multichannel_map::document_to_psd(doc, opts.force_psb); + return crate::multichannel_map::document_to_psd(doc, opts.force_psb, layerless_limit); } - if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) { + if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb, layerless_limit) { return file; } let fmt = doc.pixel_format(); + export_supported_document(doc, opts, depth, ctl, fmt, size_limit) +} + +fn export_supported_document( + doc: &Document, + opts: &PsdExportOptions, + depth: u32, + ctl: &Interrupt, + fmt: PixelFormat, + size_limit: u64, +) -> (PsdFile, Vec) { // A Duotone is written over its grey plate with its inks: the block it was imported with, // verbatim, while the inks are unedited, else one made from them (`duotone_map`). Photoshop // has only 8-bit Duotone: it refuses to open a 16-bit one. @@ -43,8 +65,7 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = opts.size_limit_psb && !opts.force_psb && !big && psd_size_exceeds_limit(doc); - let version = psd_version(doc, opts.force_psb, size_exceeds_limit); + let version = psd_version(doc, opts.force_psb); let mut ex = Ex { fmt, dpi: doc.resolution_dpi, @@ -68,72 +89,8 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de patterns: doc.patterns.clone(), background: psd_background(doc), }; - if big && !opts.force_psb { - ex.warnings.push("document exceeds 30000 px; written as PSB".into()); - } else if size_exceeds_limit && !opts.force_psb { - ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into()); - } - if fmt.mode != doc.mode && duotone_inks.is_none() { - ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode)); - } - // Assign layer ids up front (stable across round trips): keep unique - // `psd_id`s, number the rest after them; dividers come after all. - { - let walk = doc.walk(); - let mut used = std::collections::HashSet::new(); - for (_, _, l) in &walk { - if let Some(id) = l.psd_id - && used.insert(id) - { - ex.layer_ids.insert(l.id, id); - } - } - let mut next = 1u32; - for (_, _, l) in &walk { - if let std::collections::hash_map::Entry::Vacant(e) = ex.layer_ids.entry(l.id) { - while used.contains(&next) { - next += 1; - } - used.insert(next); - e.insert(next); - } - } - ex.next_id = used.iter().copied().max().unwrap_or(0); - ex.used_ids = used; - // Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the - // number it already names — its `Txt2` object must sit at that slot for `apply_txt2` — - // and new layers get numbers past every kept one and every object in the file's `Txt2`. - // Kept numbers are claimed first: a layer added below others must not take a number a - // layer above it keeps, or each of those would be renumbered onto its neighbour's object - // and take its style (#7773); nor may it land on a deleted layer's object. - let mut used_txt = std::collections::HashSet::new(); - ex.text_index = Default::default(); - let mut fresh = Vec::new(); - for (_, _, l) in &walk { - let LayerContent::Text(t) = &l.content else { - continue; - }; - match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) { - Some(i) if used_txt.insert(i) => { - ex.text_index.insert(l.id, i); - } - _ => fresh.push(l.id), - } - } - let objects = - doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d)); - let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX)); - if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 { - next_txt = 0; - } - for id in fresh { - while used_txt.contains(&next_txt) { - next_txt += 1; - } - used_txt.insert(next_txt); - ex.text_index.insert(id, next_txt); - } - } + add_format_warnings(doc, opts, fmt, big, duotone_inks.is_some(), &mut ex.warnings); + index_layer_ids_and_text(doc, &mut ex); // Progress: one unit per layer record, then the merged composite (#3389). let records = doc.walk().len() as f32; let units = records + MERGED_WEIGHT; @@ -146,66 +103,11 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de }; let space = photocraft_compose::cmyk_space(doc); photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick)); + drop(tick); + ctl.progress(layers_share); - // Without Maximize Compatibility the merged image is a white placeholder and the composite - // isn't rendered at all. A document without layer records keeps the real image: its pixels - // live nowhere else. So does a Duotone: it opens flattened from that image (its layer records - // are not read back). - let real_merged = opts.maximize_compatibility || ex.records.is_empty() || duotone_inks.is_some(); - let (mut planes, has_alpha) = if real_merged { - // Merged composite, rendered and encoded in bands (no full-size float composite). Matting - // against white only changes pixels with alpha < 1; if some are slightly translucent but - // all round to opaque (so no alpha channel is written), encode once more without the matte. - let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; - let (mut planes, has_alpha, translucent) = - crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); - if !has_alpha && translucent && opts.merged_matte { - planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; - } - (planes, has_alpha) - } else { - (placeholder_planes(doc, &fmt), false) - }; - let n = doc.size.area() as usize; - if !has_alpha { - planes.truncate(cc * n * sample.bytes()); - } - let canvas = doc.bounds(); - let max_extra = 56 - cc - usize::from(has_alpha); - if doc.channels.len() > max_extra { - ex.warnings.push(format!("only {max_extra} alpha channels fit in PSD; the rest were dropped")); - } - let mut extra: Vec<_> = doc.channels.iter().take(max_extra).collect(); - // Saved in Quick Mask mode: the mask is written as the last extra channel and flagged by - // resource 1022 (quick mask info: channel id, initially-empty flag), as Photoshop does. - let quick_mask_id = match &doc.quick_mask { - Some(q) if extra.len() < max_extra => { - extra.push(q); - Some((cc + usize::from(has_alpha) + extra.len() - 1) as u16) - } - Some(_) => { - ex.warnings.push("Quick Mask was dropped because the PSD channel limit was reached".into()); - None - } - None => None, - }; - for a in &extra { - let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; - let mut bytes = s.to_interleaved(canvas); - // Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight. - if a.spot.is_some() { - crate::channel_map::photoshop_spot_plane(&mut bytes, sample); - } - planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); - } - let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; - let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; - let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), mode); - let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; - let image_data = ImageData::encode(mcomp, &planes, &header) - .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); - ctl.progress(1.0); + let merged = prepare_merged_planes(doc, &mut ex, opts, ctl, fmt, duotone_inks.is_some(), layers_share); + let MergedExport { header, image_data, extra, has_alpha, quick_mask_id, real_merged } = merged; // Resources. let mut resources = vec![ @@ -281,7 +183,7 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - let block = global_block(block_signature(sig, key, version), key, &data); + let block = global_block(sig, key, &data); // A kept merged-transparency marker describes the opened file's composite. Over an opaque // one it would make the first alpha channel read back as the transparency, and vanish. if !has_alpha && block.is_merged_transparency_marker() { @@ -362,7 +264,7 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de Some(raw) => raw, None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), }; - let file = PsdFile { + let mut file = PsdFile { header, color_mode_data, resources, @@ -372,10 +274,178 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de global_blocks, layer_mask_trailing: Vec::new(), image_data, + write_as_psb: false, }; + if opts.size_limit_psb + && let Some(warning) = fit_psd_size(&mut file, size_limit) + { + ex.warnings.push(warning); + } + sign_global_blocks(&mut file); (file, ex.warnings) } +fn add_format_warnings(doc: &Document, opts: &PsdExportOptions, fmt: PixelFormat, big: bool, has_duotone: bool, warnings: &mut Vec) { + if big && !opts.force_psb { + warnings.push("document exceeds 30000 px; written as PSB".into()); + } + if fmt.mode != doc.mode && !has_duotone { + warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode)); + } +} + +struct MergedExport<'a> { + header: Header, + image_data: ImageData, + extra: Vec<&'a photocraft_doc::AlphaChannel>, + has_alpha: bool, + quick_mask_id: Option, + real_merged: bool, +} + +fn prepare_merged_planes<'a>( + doc: &'a Document, + ex: &mut Ex, + opts: &PsdExportOptions, + ctl: &Interrupt, + fmt: PixelFormat, + duotone: bool, + layers_share: f32, +) -> MergedExport<'a> { + let sample = fmt.sample; + let cc = fmt.mode.color_channels(); + let real_merged = opts.maximize_compatibility || ex.records.is_empty() || duotone; + let (mut planes, has_alpha) = merged_image(doc, opts, ctl, &fmt, real_merged, layers_share); + if !has_alpha { + planes.truncate(cc * doc.size.area() as usize * sample.bytes()); + } + let (extra, quick_mask_id) = append_extra_channels(doc, ex, &mut planes, has_alpha, sample); + let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; + let mode = if duotone { PsdMode::Duotone } else { psd_mode(fmt.mode) }; + let header = Header::new(ex.version, doc.size.width, doc.size.height, channels, psd_depth(sample), mode); + let compression = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; + let image_data = ImageData::encode(compression, &planes, &header).or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)).unwrap_or(ImageData { + compression: Compression::Raw, + data: planes, + stored: None, + }); + ctl.progress(1.0); + MergedExport { header, image_data, extra, has_alpha, quick_mask_id, real_merged } +} + +fn append_extra_channels<'a>( + doc: &'a Document, + ex: &mut Ex, + planes: &mut Vec, + has_alpha: bool, + sample: SampleType, +) -> (Vec<&'a photocraft_doc::AlphaChannel>, Option) { + let cc = ex.cc; + let max_extra = 56 - cc - usize::from(has_alpha); + if doc.channels.len() > max_extra { + ex.warnings.push(format!("only {max_extra} alpha channels fit in PSD; the rest were dropped")); + } + let mut extra: Vec<_> = doc.channels.iter().take(max_extra).collect(); + let quick_mask_id = match &doc.quick_mask { + Some(q) if extra.len() < max_extra => { + extra.push(q); + Some((cc + usize::from(has_alpha) + extra.len() - 1) as u16) + } + Some(_) => { + ex.warnings.push("Quick Mask was dropped because the PSD channel limit was reached".into()); + None + } + None => None, + }; + let canvas = doc.bounds(); + for channel in &extra { + let surface = if channel.surface.format() != ex.mask_fmt { channel.surface.convert(ex.mask_fmt) } else { channel.surface.clone() }; + let mut bytes = surface.to_interleaved(canvas); + if channel.spot.is_some() { + crate::channel_map::photoshop_spot_plane(&mut bytes, sample); + } + planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); + } + (extra, quick_mask_id) +} + +fn merged_image(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt, fmt: &PixelFormat, real_merged: bool, layers_share: f32) -> (Vec, bool) { + if !real_merged { + return (placeholder_planes(doc, fmt), false); + } + // Merged composite, rendered and encoded in bands (no full-size float composite). Matting + // against white only changes pixels with alpha < 1; if some are slightly translucent but + // all round to opaque (so no alpha channel is written), encode once more without the matte. + let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; + let (mut planes, has_alpha, translucent) = + crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, fmt, cmyk_of(fmt), opts.merged_matte, ctl)); + if !has_alpha && translucent && opts.merged_matte { + planes = merged_planes(doc, fmt, cmyk_of(fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; + } + (planes, has_alpha) +} + +fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex) { + // Assign layer ids up front (stable across round trips): keep unique + // `psd_id`s, number the rest after them; dividers come after all. + { + let walk = doc.walk(); + let mut used = std::collections::HashSet::new(); + for (_, _, l) in &walk { + if let Some(id) = l.psd_id + && used.insert(id) + { + ex.layer_ids.insert(l.id, id); + } + } + let mut next = 1u32; + for (_, _, l) in &walk { + if let std::collections::hash_map::Entry::Vacant(e) = ex.layer_ids.entry(l.id) { + while used.contains(&next) { + next += 1; + } + used.insert(next); + e.insert(next); + } + } + ex.next_id = used.iter().copied().max().unwrap_or(0); + ex.used_ids = used; + // Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the + // number it already names — its `Txt2` object must sit at that slot for `apply_txt2` — + // and new layers get numbers past every kept one and every object in the file's `Txt2`. + // Kept numbers are claimed first: a layer added below others must not take a number a + // layer above it keeps, or each of those would be renumbered onto its neighbour's object + // and take its style (#7773); nor may it land on a deleted layer's object. + let mut used_txt = std::collections::HashSet::new(); + ex.text_index = Default::default(); + let mut fresh = Vec::new(); + for (_, _, l) in &walk { + let LayerContent::Text(t) = &l.content else { + continue; + }; + match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) { + Some(i) if used_txt.insert(i) => { + ex.text_index.insert(l.id, i); + } + _ => fresh.push(l.id), + } + } + let objects = + doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d)); + let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX)); + if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 { + next_txt = 0; + } + for id in fresh { + while used_txt.contains(&next_txt) { + next_txt += 1; + } + used_txt.insert(next_txt); + ex.text_index.insert(id, next_txt); + } + } +} + /// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when /// every value is the default (density 1, feather 0). pub(super) fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option { diff --git a/crates/io/src/psd_export/estimate.rs b/crates/io/src/psd_export/estimate.rs index 83c2388a1e..bb2d9b1c59 100644 --- a/crates/io/src/psd_export/estimate.rs +++ b/crates/io/src/psd_export/estimate.rs @@ -1,115 +1,5 @@ -use super::*; - -/// How much of a PSD conversion the merged composite weighs, in layer records: rendering it -/// (all layers composited) and encoding its planes cost about two full-canvas layers. -pub(super) const MERGED_WEIGHT: f32 = 2.0; - -/// The part of the merged composite's share filled once its bands are rendered and converted; the -/// rest is compressing its planes. -pub(super) const MERGED_RENDERED: f32 = 0.8; - -/// Adds headroom for compressed channel streams and their per-row length tables. -pub(super) fn estimate_encoded_size(raw_bytes: u64, rows: u64, structural_bytes: u64) -> Option { - raw_bytes.checked_add(raw_bytes.checked_add(63)?.checked_div(64)?)?.checked_add(rows.checked_mul(8)?)?.checked_add(structural_bytes) -} - -fn add_plane_estimate(raw_bytes: &mut u64, rows: &mut u64, width: u64, height: u64, channels: u64, bytes_per_sample: u64) -> Option<()> { - let plane_bytes = width.checked_mul(height)?.checked_mul(channels)?.checked_mul(bytes_per_sample)?; - let plane_rows = height.checked_mul(channels)?; - *raw_bytes = raw_bytes.checked_add(plane_bytes)?; - *rows = rows.checked_add(plane_rows)?; - Some(()) -} - -pub(super) fn exceeds_psd_size_limit(estimated_size: Option) -> bool { - estimated_size.is_none_or(|size| size > PSD_MAX_FILE_SIZE) -} - -fn surface_dimensions(surface: &Surface) -> Option<(u64, u64)> { - let bounds = surface.content_bounds(); - Some((u64::from(bounds.width()), u64::from(bounds.height()))) -} - -pub(crate) fn estimate_psd_size(doc: &Document) -> Option { - let mut raw_bytes = 0; - let mut rows = 0; - let mut structural_bytes = PSD_ESTIMATE_FIXED_OVERHEAD; - - let width = u64::from(doc.size.width); - let height = u64::from(doc.size.height); - - if doc.mode == ColorMode::Multichannel { - let channels = u64::try_from(doc.channels.len().clamp(1, 56)).ok()?; - let sample_bytes = u64::try_from(if doc.depth == SampleType::F32 { SampleType::U16.bytes() } else { doc.depth.bytes() }).ok()?; - add_plane_estimate(&mut raw_bytes, &mut rows, width, height, channels, sample_bytes)?; - } else { - let format = doc.pixel_format(); - let color_channels = u64::try_from(format.mode.color_channels()).ok()?; - let bytes_per_sample = u64::try_from(format.sample.bytes()).ok()?; - let layer_channels = color_channels.checked_add(1)?; - - for (_, _, layer) in doc.walk() { - structural_bytes = structural_bytes.checked_add(PSD_ESTIMATE_LAYER_OVERHEAD)?.checked_add(u64::try_from(layer.name.len()).ok()?)?; - for (_, data) in &layer.psd_blocks { - structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?; - } - - let surface = match &layer.content { - LayerContent::Fill(fill) => { - let mut fill_width = width; - let mut fill_height = height; - if let Some(cache) = &layer.fill_cache - && cache.fill == *fill - { - let (cache_width, cache_height) = surface_dimensions(&cache.surface)?; - fill_width = fill_width.max(cache_width); - fill_height = fill_height.max(cache_height); - } - add_plane_estimate(&mut raw_bytes, &mut rows, fill_width, fill_height, layer_channels, bytes_per_sample)?; - None - } - _ => layer.surface(), - }; - if let Some(surface) = surface { - let (surface_width, surface_height) = surface_dimensions(surface)?; - add_plane_estimate(&mut raw_bytes, &mut rows, surface_width, surface_height, layer_channels, bytes_per_sample)?; - } - if let Some(mask) = &layer.mask { - let (mask_width, mask_height) = surface_dimensions(&mask.surface)?; - add_plane_estimate(&mut raw_bytes, &mut rows, mask_width, mask_height, 1, bytes_per_sample)?; - } - if let LayerContent::Smart(smart) = &layer.content - && let photocraft_doc::SmartSource::Embedded { bytes, .. } = &smart.source - { - structural_bytes = structural_bytes.checked_add(u64::try_from(bytes.len()).ok()?)?; - } - } - - let max_extra_channels = 56usize.saturating_sub(format.mode.color_channels() + 1); - let mut extra_channels = doc.channels.len().min(max_extra_channels); - if doc.quick_mask.is_some() && extra_channels < max_extra_channels { - extra_channels += 1; - } - let merged_channels = color_channels.checked_add(1)?.checked_add(u64::try_from(extra_channels).ok()?)?; - add_plane_estimate(&mut raw_bytes, &mut rows, width, height, merged_channels, bytes_per_sample)?; - for channel in doc.channels.iter().take(extra_channels) { - structural_bytes = structural_bytes.checked_add(u64::try_from(channel.name.len()).ok()?)?; - } - } - - structural_bytes = structural_bytes - .checked_add(u64::try_from(doc.icc_profile.as_ref().map_or(0, |profile| profile.len())).ok()?)? - .checked_add(u64::try_from(doc.metadata.xmp.as_deref().map_or(0, str::len)).ok()?)? - .checked_add(u64::try_from(doc.metadata.exif.as_ref().map_or(0, |data| data.len())).ok()?)?; - for (_, name, data) in &doc.metadata.psd_resources { - structural_bytes = structural_bytes.checked_add(u64::try_from(name.len()).ok()?)?.checked_add(u64::try_from(data.len()).ok()?)?; - } - for (_, _, data) in &doc.metadata.psd_global_blocks { - structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?; - } - - estimate_encoded_size(raw_bytes, rows, structural_bytes) -} +use super::{Document, PSD_SAVE_HEADROOM}; +use photocraft_psd::{PsdFile, Version, file::LayerInfoPlacement}; /// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD /// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by @@ -125,11 +15,49 @@ pub(crate) fn block_signature(sig: [u8; 4], key: [u8; 4], version: Version) -> [ } } -pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version { +/// How much of a PSD conversion the merged composite weighs, in layer records. +pub(super) const MERGED_WEIGHT: f32 = 2.0; + +/// The part of the merged composite's share filled once its bands are rendered and converted. +pub(super) const MERGED_RENDERED: f32 = 0.8; + +/// The version a document is encoded as: PSB when asked for or past PSD's 30000 px; otherwise +/// PSD, which [`fit_psd_size`] turns into PSB once the encoded file proves too large. +pub(crate) fn psd_version(doc: &Document, force_psb: bool) -> Version { let exceeds_dimensions = doc.size.width > 30_000 || doc.size.height > 30_000; - if force_psb || exceeds_dimensions || size_exceeds_limit { Version::Psb } else { Version::Psd } + if force_psb || exceeds_dimensions { Version::Psb } else { Version::Psd } } -pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool { - exceeds_psd_size_limit(estimate_psd_size(doc)) +/// Promote files that exceed the PSD limit, accounting for the preview appended by the save +/// (#212, #5285). The decision is made on the exact length of the file as it will be written, +/// not on the raw pixel size: RLE usually packs the channels far below it. +pub(crate) fn fit_psd_size(file: &mut PsdFile, limit: u64) -> Option { + if file.written_version() == Version::Psb { + return None; + } + if file.encoded_len().is_ok_and(|len| len.saturating_add(PSD_SAVE_HEADROOM) <= limit) { + return None; + } + match file.promote_to_psb() { + Ok(()) => { + let padded = file.layer_info.as_ref().is_some_and(|info| info.padding.is_some()); + if matches!(file.layer_info_placement, LayerInfoPlacement::Section) + && padded + && file.pad_layer_info_to(4).is_err() + && let Some(info) = &mut file.layer_info + { + info.padding = None; + } + Some("encoded size exceeds 2 GB; written as PSB".into()) + } + Err(error) => Some(format!("encoded size exceeds 2 GB, but the file could not be written as PSB: {error}")), + } +} + +/// Signs every global block for the version the file is written as. +pub(crate) fn sign_global_blocks(file: &mut PsdFile) { + let version = file.written_version(); + for block in &mut file.global_blocks { + block.signature = block_signature(block.signature, block.key, version); + } } diff --git a/crates/io/src/psd_export/merged.rs b/crates/io/src/psd_export/merged.rs index da3afe0597..94b7bc4014 100644 --- a/crates/io/src/psd_export/merged.rs +++ b/crates/io/src/psd_export/merged.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ColorMode, Document, Interrupt, PixelFormat, SampleType, encode_be, q255}; pub(super) fn cmyk_of(fmt: &PixelFormat) -> bool { fmt.mode == ColorMode::Cmyk @@ -29,39 +29,58 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte if ctl.cancelled() { return Err(()); } - // Retain alpha whenever it differs from opaque at the stored precision. - has_alpha |= band.px.iter().any(|p| match sample { - SampleType::U8 => q255(p[3]) < 255, - SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX, - SampleType::F32 => p[3] != 1.0, - }); - translucent |= band.px.iter().any(|p| p[3] < 1.0); let start = (band.rect.y0 - canvas.y0) as usize * w; - // Converted and encoded on all cores, then copied into each plane. - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - // The composite came through the document's CMYK profile: convert back through it too. - photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut out: Vec> = vec![Vec::with_capacity(range.len() * bps); cc + 1]; - let mut v = [0.0f32; 5]; - for p in &band.px[range.clone()] { - photocraft_raster::from_rgba_into(fmt, *p, &mut v); - for c in 0..=cc { - // Matte against white like Photoshop (see `pixels::matte`). - let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] }; - let x = if cmyk && c < cc { 1.0 - m } else { m }; - // 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`). - let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; - encode_be(x, sample, &mut out[c]); - } - } - (range.start, out) + let ranges = crate::pixels::bands(band.px.len()); + let mut slots: Vec> = ranges.iter().map(|_| Vec::with_capacity(cc + 1)).collect(); + for chunk in planes.chunks_mut(plane.max(1)).take(cc + 1) { + let Some(mut rest) = chunk.get_mut(start * bps..(start + band.px.len()) * bps) else { continue }; + for (slot, range) in slots.iter_mut().zip(&ranges) { + let Some((part, tail)) = rest.split_at_mut_checked(range.len() * bps) else { break }; + slot.push(part); + rest = tail; + } + } + // Converted and encoded on all cores. The composite came through the document's CMYK + // profile: convert back through it too. + let flags = crate::pixels::par_map(ranges.into_iter().zip(slots).collect(), |(range, mut out)| { + let px = band.px.get(range).unwrap_or(&[]); + let k = Encode { fmt, cc, matte, cmyk, lab16, white: &white }; + photocraft_color::convert::with_cmyk_space(space.as_ref(), || match sample { + SampleType::U8 => k.run( + px, + &mut out, + |a| q255(a) < 255, + |x, d, i| { + if let Some(b) = d.get_mut(i) { + *b = (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + }, + ), + SampleType::U16 => k.run( + px, + &mut out, + |a| (a.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX, + |x, d, i| { + if let Some(b) = d.get_mut(i * 2..i * 2 + 2) { + b.copy_from_slice(&((x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_be_bytes()); + } + }, + ), + SampleType::F32 => k.run( + px, + &mut out, + |a| a != 1.0, + |x, d, i| { + if let Some(b) = d.get_mut(i * 4..i * 4 + 4) { + b.copy_from_slice(&x.to_be_bytes()); + } + }, + ), }) }); - for (at, part) in parts { - for (c, src) in part.iter().enumerate() { - let o = c * plane + (start + at) * bps; - planes[o..o + src.len()].copy_from_slice(src); - } + for (a, t) in flags { + has_alpha |= a; + translucent |= t; } ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows); Ok(()) @@ -69,6 +88,38 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte (planes, has_alpha, translucent) } +struct Encode<'a> { + fmt: &'a PixelFormat, + cc: usize, + matte: bool, + cmyk: bool, + lab16: bool, + white: &'a [f32], +} + +impl Encode<'_> { + fn run(&self, px: &[[f32; 4]], out: &mut [&mut [u8]], stored_translucent: impl Fn(f32) -> bool, put: impl Fn(f32, &mut [u8], usize)) -> (bool, bool) { + let Self { fmt, cc, matte, cmyk, lab16, white } = *self; + let (mut has_alpha, mut translucent) = (false, false); + let mut v = [0.0f32; 5]; + for (i, p) in px.iter().enumerate() { + // Retain alpha whenever it differs from opaque at the stored precision. + has_alpha |= stored_translucent(p[3]); + translucent |= p[3] < 1.0; + photocraft_raster::from_rgba_into(fmt, *p, &mut v); + for (c, dst) in out.iter_mut().enumerate() { + // Matte against white like Photoshop (see `pixels::matte`). + let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white.get(c).copied().unwrap_or(1.0)) } else { v[c] }; + let x = if cmyk && c < cc { 1.0 - m } else { m }; + // 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`). + let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; + put(x, dst, i); + } + } + (has_alpha, translucent) + } +} + /// [`merged_planes`] of a CMYK document whose layers are mixed on their ink values /// (`photocraft_compose::cmyk`), so opaque pixels keep their exact inks; `None` for documents /// that composite in RGB. Reports progress and stops on cancel like [`merged_planes`]. @@ -141,3 +192,82 @@ pub(super) fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec { } planes } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Layer, LayerContent}; + use photocraft_raster::Surface; + + fn previous_merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec, bool, bool) { + if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, &Interrupt::NONE) { + return planes; + } + let sample = fmt.sample; + let cc = fmt.mode.color_channels(); + let bps = sample.bytes(); + let plane = doc.size.area() as usize * bps; + let mut planes = vec![0u8; plane * (cc + 1)]; + let (mut has_alpha, mut translucent) = (false, false); + let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]); + let canvas = doc.bounds(); + let w = canvas.width() as usize; + let space = photocraft_compose::cmyk_space(doc); + let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16; + let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + has_alpha |= band.px.iter().any(|p| match sample { + SampleType::U8 => q255(p[3]) < 255, + SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX, + SampleType::F32 => p[3] != 1.0, + }); + translucent |= band.px.iter().any(|p| p[3] < 1.0); + let start = (band.rect.y0 - canvas.y0) as usize * w; + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut v = [0.0f32; 5]; + for (i, p) in band.px.iter().enumerate() { + photocraft_raster::from_rgba_into(fmt, *p, &mut v); + for c in 0..=cc { + let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] }; + let x = if cmyk && c < cc { 1.0 - m } else { m }; + let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; + let mut b = Vec::new(); + encode_be(x, sample, &mut b); + let o = c * plane + (start + i) * bps; + planes[o..o + bps].copy_from_slice(&b); + } + } + }); + Ok(()) + }); + (planes, has_alpha, translucent) + } + + #[test] + fn merged_planes_match_the_per_band_reference() { + let size = photocraft_geom::Size::new(37, 23); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] { + for sample in SampleType::ALL { + for alpha_floor in [0.0f32, 0.999_9, 1.0] { + let mut doc = Document::new("merged", size, mode, sample); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + for y in 0..23 { + for x in 0..37 { + let a = (alpha_floor + (1.0 - alpha_floor) * ((x * 7 + y * 3) % 11) as f32 / 10.0).min(1.0); + let rgba = [x as f32 / 36.0, y as f32 / 22.0, ((x + y) % 5) as f32 / 4.0, a]; + let mut v = [0.0f32; 5]; + let n = photocraft_raster::from_rgba_into(&fmt, rgba, &mut v); + s.write_pixel(x, y, &v[..n]); + } + } + doc.layers.push(Layer::new("pixels", LayerContent::Raster(s))); + for matte in [false, true] { + let got = merged_planes(&doc, &fmt, cmyk_of(&fmt), matte, &Interrupt::NONE); + let want = previous_merged_planes(&doc, &fmt, cmyk_of(&fmt), matte); + assert_eq!(got, want, "{mode:?} {sample:?} alpha >= {alpha_floor} matte {matte}"); + } + } + } + } + } +} diff --git a/crates/io/src/psd_export/resources.rs b/crates/io/src/psd_export/resources.rs index 4ab5210e94..09d9e67d22 100644 --- a/crates/io/src/psd_export/resources.rs +++ b/crates/io/src/psd_export/resources.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, ImageResource, TaggedBlock}; pub(crate) fn legacy_name(s: &str) -> Vec { s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect() diff --git a/crates/io/src/psd_export/smart.rs b/crates/io/src/psd_export/smart.rs index 264a2ab8ab..10833c7033 100644 --- a/crates/io/src/psd_export/smart.rs +++ b/crates/io/src/psd_export/smart.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Layer, LayerContent, SmartContentsId, Surface}; /// An embedded smart-object source as written to `lnk2`. #[derive(Clone, Debug)] diff --git a/crates/io/src/psd_export/tests.rs b/crates/io/src/psd_export/tests.rs index 9688dfeac2..af7c8a2241 100644 --- a/crates/io/src/psd_export/tests.rs +++ b/crates/io/src/psd_export/tests.rs @@ -337,33 +337,161 @@ fn light_adjustment_is_kept_verbatim_and_warned() { assert!(!crate::adjust_map::is_extended_brit(Some(&plain[1].1))); } -#[test] -fn size_limit_boundary_keeps_limit_and_switches_above_it() { - assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE))); - assert!(exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE + 1))); - assert!(exceeds_psd_size_limit(None)); +/// A 1024² RGB document with one full-canvas layer: flat colour (packs to a few KB) or noise +/// (RLE can't pack it). Its raw planes (layer RGBA + merged RGB) are 7 MiB either way. +fn sized_document(noise: bool, depth: SampleType) -> Document { + const SIDE: u32 = 1024; + let mut doc = Document::new("size limit", photocraft_geom::Size::new(SIDE, SIDE), ColorMode::Rgb, depth); + let mut layer = Layer::raster("Layer 1", doc.pixel_format()); + let rect = photocraft_geom::Rect::new(0, 0, SIDE as i32, SIDE as i32); + let surface = layer.surface_mut().unwrap(); + if noise { + let mut state = 0x2545_f491_u32; + let data: Vec = (0..SIDE * SIDE * 4) + .map(|i| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + if i % 4 == 3 { 1.0 } else { (state >> 24) as f32 / 255.0 } + }) + .collect(); + surface.write_region(rect, &data); + } else { + surface.fill_rect(rect, &[0.2, 0.4, 0.6, 1.0]); + } + doc.layers.push(layer); + doc } -#[test] -fn encoded_size_estimator_rejects_arithmetic_overflow() { - assert_eq!(estimate_encoded_size(u64::MAX, 0, 0), None); - assert_eq!(estimate_encoded_size(0, u64::MAX, 0), None); - assert_eq!(estimate_encoded_size(0, 0, u64::MAX), Some(u64::MAX)); - assert!(exceeds_psd_size_limit(estimate_psd_size(&document(u32::MAX, u32::MAX)))); +const SIZED_RAW_BYTES: u64 = 1024 * 1024 * (4 + 3); +/// Under the raw size, so only the encoded size can keep a document PSD. +const SIZED_LIMIT: u64 = 4 * 1024 * 1024; +const _: () = assert!(SIZED_RAW_BYTES > SIZED_LIMIT); + +fn export_sized(doc: &Document, limit: u64) -> (PsdFile, Vec) { + document_to_psd_sized(doc, &PsdExportOptions::default(), 0, &Interrupt::NONE, limit) } +/// #5285: a document whose raw pixels are over the PSD limit but whose encoded file fits is +/// saved as PSD (the raw-size estimate used to make it a PSB). #[test] -fn large_encoded_size_selects_psb_without_changing_explicit_choice() { - let large = document(30_000, 30_000); - assert!(estimate_psd_size(&large).is_some_and(|size| size > PSD_MAX_FILE_SIZE)); - let large_size_exceeds_limit = psd_size_exceeds_limit(&large); - assert_eq!(psd_version(&large, false, large_size_exceeds_limit), Version::Psb); +fn a_file_that_fits_after_compression_stays_psd() { + let doc = sized_document(false, SampleType::U8); + let (file, warnings) = export_sized(&doc, SIZED_LIMIT); + assert_eq!(file.written_version(), Version::Psd); + assert!(warnings.iter().all(|w| !w.contains("PSB")), "{warnings:?}"); + let bytes = file.to_bytes().unwrap(); + assert!(bytes.len() as u64 + PSD_SAVE_HEADROOM <= SIZED_LIMIT, "{} bytes", bytes.len()); + let (back, _) = crate::psd_to_document(&PsdFile::from_bytes(&bytes).unwrap()); + assert_eq!(back.layers[0].surface().unwrap().pixel(700, 300), doc.layers[0].surface().unwrap().pixel(700, 300)); +} +/// #5285: a document whose encoded file is over the limit becomes a PSB, byte for byte the +/// one a forced PSB save writes, and reads back. 8 bits keep the layers in the layer section, +/// 16 bits in a global `Lr16` block. +#[test] +fn a_file_over_the_limit_after_compression_is_psb() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = sized_document(true, depth); + let (file, warnings) = export_sized(&doc, SIZED_LIMIT); + assert_eq!(file.written_version(), Version::Psb, "{depth:?}"); + assert!(warnings.iter().any(|w| w == "encoded size exceeds 2 GB; written as PSB"), "{warnings:?}"); + let bytes = file.to_bytes().unwrap(); + assert!(bytes.len() as u64 > SIZED_LIMIT); + let forced = document_to_psd_with(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }).0; + let forced = forced.to_bytes().unwrap(); + assert!(bytes == forced, "{depth:?}: the promoted PSB differs from a PSB written directly"); + assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), PsdFile::from_bytes(&forced).unwrap(), "{depth:?}"); + // The promoted model itself stays a consistent PSD for every reader. + assert_eq!(file.header.version, Version::Psd); + assert_eq!(crate::psd_to_document(&file).0.layers[0].surface().unwrap().pixel(511, 777), doc.layers[0].surface().unwrap().pixel(511, 777)); + let (back, _) = crate::psd_to_document(&PsdFile::from_bytes(&bytes).unwrap()); + for (x, y) in [(0, 0), (1023, 0), (511, 777), (1023, 1023)] { + assert_eq!(back.layers[0].surface().unwrap().pixel(x, y), doc.layers[0].surface().unwrap().pixel(x, y), "{depth:?} ({x}, {y})"); + } + } +} + +/// The limit is the exact written length plus the save's headroom: one byte less is a PSB. +#[test] +fn the_size_limit_is_the_exact_encoded_length() { + let doc = sized_document(false, SampleType::U8); + let len = export_sized(&doc, u64::MAX).0.to_bytes().unwrap().len() as u64; + assert_eq!(export_sized(&doc, len + PSD_SAVE_HEADROOM).0.written_version(), Version::Psd); + assert_eq!(export_sized(&doc, len + PSD_SAVE_HEADROOM - 1).0.written_version(), Version::Psb); + // The real limit: a small document stays PSD, a forced one is PSB. let small = document(1, 1); - let small_size_exceeds_limit = psd_size_exceeds_limit(&small); - assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd); - assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb); + assert_eq!(document_to_psd(&small).written_version(), Version::Psd); + assert_eq!(document_to_psd_with(&small, &PsdExportOptions { force_psb: true, ..Default::default() }).0.written_version(), Version::Psb); + assert_eq!(psd_version(&document(30_001, 1), false), Version::Psb); } + +/// A Multichannel document follows the same rule. +#[test] +fn a_multichannel_file_over_the_limit_is_psb() { + let mut doc = document(64, 64); + doc.mode = ColorMode::Multichannel; + doc.channels.push(photocraft_doc::AlphaChannel::new("Cyan", photocraft_raster::Surface::new(PixelFormat::GRAY8))); + let (file, _) = export_sized(&doc, u64::MAX); + assert_eq!(file.written_version(), Version::Psd); + let len = file.to_bytes().unwrap().len() as u64; + let (file, warnings) = export_sized(&doc, len + PSD_SAVE_HEADROOM - 1); + assert_eq!(file.written_version(), Version::Psb); + assert!(warnings.iter().any(|w| w.contains("written as PSB")), "{warnings:?}"); + let back = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap(); + assert_eq!(back.header.version, Version::Psb); + assert_eq!(back.decode_merged().unwrap(), PsdFile::from_bytes(&export_sized(&doc, u64::MAX).0.to_bytes().unwrap()).unwrap().decode_merged().unwrap()); +} + +/// Indexed Color and Bitmap (written without layers, #4816), Multichannel and RGB follow the +/// encoded-size rule, and a file turned into PSB by its size writes its global blocks with +/// 8-byte lengths as `8B64` (#7937), whatever signature they were kept with; a file that fits +/// stays a PSD with `8BIM` throughout. +#[test] +fn every_mode_promoted_by_size_signs_long_blocks_8b64() { + let white = photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0); + let mut docs = Vec::new(); + for (mode, psd_mode) in [(ColorMode::Indexed, PsdMode::Indexed), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Rgb, PsdMode::Rgb)] { + let mut doc = Document::with_background("modes", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8, white); + doc.mode = mode; + doc.color_table = Some(photocraft_doc::ColorTable { colors: vec![[255, 255, 255], [0, 0, 0]], transparent: None }); + docs.push((doc, psd_mode)); + } + let mut multichannel = document(64, 64); + multichannel.mode = ColorMode::Multichannel; + multichannel.channels.push(photocraft_doc::AlphaChannel::new("Cyan", photocraft_raster::Surface::new(PixelFormat::GRAY8))); + docs.push((multichannel, PsdMode::Multichannel)); + for (mut doc, psd_mode) in docs { + doc.metadata.psd_global_blocks = vec![ + (*b"8BIM", *b"cinf", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])), + (*b"8BIM", *b"pths", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])), + (*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 50])), + (*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), + ]; + let written = |limit| { + let (file, warnings) = export_sized(&doc, limit); + let bytes = file.to_bytes().unwrap(); + let back = PsdFile::from_bytes(&bytes).unwrap(); + let blocks = back.global_blocks.iter().map(|b| (b.signature, b.key)).collect::>(); + (bytes.len() as u64, back, blocks, warnings) + }; + let (len, small, blocks, _) = written(u64::MAX); + assert_eq!((small.header.version, small.header.color_mode), (Version::Psd, psd_mode), "{psd_mode:?}"); + assert_eq!(blocks, vec![(*b"8BIM", *b"cinf"), (*b"8BIM", *b"pths"), (*b"8BIM", *b"FMsk"), (*b"8BIM", *b"Patt")], "{psd_mode:?}: a PSD keeps 8BIM"); + // One byte short of the encoded length: the same document is written as PSB. + let (_, big, blocks, warnings) = written(len + PSD_SAVE_HEADROOM - 1); + assert_eq!((big.header.version, big.header.color_mode), (Version::Psb, psd_mode), "{psd_mode:?}: {warnings:?}"); + assert!(warnings.iter().any(|w| w == "encoded size exceeds 2 GB; written as PSB"), "{psd_mode:?}: {warnings:?}"); + assert_eq!(blocks, vec![(*b"8B64", *b"cinf"), (*b"8B64", *b"pths"), (*b"8B64", *b"FMsk"), (*b"8BIM", *b"Patt")], "{psd_mode:?}: a PSB signs 8B64"); + assert_eq!(big.decode_merged().unwrap(), small.decode_merged().unwrap(), "{psd_mode:?}"); + // Without `size_limit_psb` no mode is promoted by its size, the layerless ones included. + let opts = PsdExportOptions { size_limit_psb: false, ..PsdExportOptions::default() }; + let (kept, warnings) = document_to_psd_sized(&doc, &opts, 0, &Interrupt::NONE, len + PSD_SAVE_HEADROOM - 1); + assert_eq!((kept.written_version(), kept.header.color_mode), (Version::Psd, psd_mode), "{psd_mode:?}: {warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("written as PSB")), "{psd_mode:?}: {warnings:?}"); + } +} + #[test] fn kept_psb_blocks_are_written_as_8bim_in_a_psd() { // Photoshop 27.11 refuses a PSD holding `8B64` blocks ("not compatible with this diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index d06cd3c335..a88773e05c 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{ - AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartContentsId, SmartObject, SmartSource, TextLayer, + AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, PsdGlobalBlock, ShapeLayer, SmartContentsId, SmartObject, SmartSource, + TextLayer, }; use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; @@ -46,6 +47,8 @@ pub(crate) const REGENERATED: [&[u8; 4]; 9] = [b"luni", b"lyid", b"lsct", b"lsdk pub(crate) struct Ctx<'a> { pub file: &'a PsdFile, + /// Global blocks shared with the document. + pub global: Vec, pub fmt: PixelFormat, pub mask_fmt: PixelFormat, pub cc: usize, @@ -53,6 +56,8 @@ pub(crate) struct Ctx<'a> { pub warnings: Vec, /// Decoding encountered damaged PSD data; interactive opens ask whether to continue. pub damaged: bool, + /// Failure to read source channel data, which must fail the open. + pub read_error: Option, /// Document resolution (type sizes are converted to points with it). pub dpi: f32, /// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning). @@ -74,9 +79,43 @@ pub(crate) struct Ctx<'a> { pub ctl: photocraft_raster::Interrupt<'a>, /// Layer records decoded so far, out of `total` (progress). pub done: usize, + /// Uniform tiles shared across imported layers. + pub uniform: photocraft_raster::TileInterner, pub total: usize, } +/// Bytes of interleaved pixels a band decoded into tiles at once aims at: whole layers up to this +/// size decode in one band, larger ones in bands of whole tile rows. +const BAND_BYTES: usize = 32 << 20; + +/// Row ranges (`0..h`, relative to a layer whose first row is at document row `top`) to decode a +/// layer in: each ends on a tile row boundary, so no tile is written by two bands, and holds +/// about [`BAND_BYTES`] of `row_bytes`-byte rows. +fn band_rows(top: i32, h: usize, row_bytes: usize) -> Vec<(usize, usize)> { + let tile = TILE_SIZE as usize; + let rows = (BAND_BYTES / row_bytes.max(1)).max(tile) / tile * tile; + // The first band ends at the first tile boundary at least `rows` rows in. + let into_tile = usize::try_from(top.rem_euclid(TILE_SIZE)).unwrap_or(0); + let mut out = Vec::new(); + let mut r0 = 0; + let mut r1 = rows - into_tile; + while r0 < h { + let end = r1.min(h); + out.push((r0, end)); + r0 = end; + r1 = end + rows; + } + out +} + +fn import_format(file: &PsdFile) -> (ColorMode, bool, bool, SampleType) { + let mode = doc_mode(file.header.color_mode).unwrap_or(ColorMode::Rgb); + let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && file.header.depth != 1; + let multichannel = mode == ColorMode::Multichannel && file.header.depth != 1; + let depth = if layered || multichannel { sample_for_depth(file.header.depth) } else { SampleType::U8 }; + (mode, layered, multichannel, depth) +} + fn doc_mode(m: PsdMode) -> Option { Some(match m { PsdMode::Bitmap => ColorMode::Bitmap, @@ -126,6 +165,18 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 pattern.then_some((fill, soft)) } +#[derive(Clone, Copy)] +struct DecodedImport<'a> { + file: &'a PsdFile, + merged: &'a Option>>, + sizes: Option<(usize, usize)>, + canvas: Rect, + layered: bool, + multichannel: bool, + depth: SampleType, + fmt: PixelFormat, +} + /// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. /// An empty `id` leaves the contents unavailable (the stored pixels stay the rendering). #[derive(Default)] @@ -148,6 +199,17 @@ impl Ctx<'_> { self.warn(s); } + /// A channel that failed to decode: a failed read of the file is kept to fail the open + /// (`true`); damaged data is warned about, its layer left empty (`false`). + fn channel_failed(&mut self, name: &str, id: i16, e: &photocraft_psd::PsdError) -> bool { + if let photocraft_psd::PsdError::Read(msg) = e { + self.read_error.get_or_insert_with(|| format!("layer \"{name}\": {msg}")); + return true; + } + self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); + false + } + /// Source id, transform, (Distort/Perspective) projective placement and warp of a placed /// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd` /// stores the placed corners but not the source size, which comes from the embedded file. @@ -168,11 +230,11 @@ impl Ctx<'_> { return Placement::default(); } }; - let (file, ctl) = (self.file, self.ctl); + let (global, ctl) = (&self.global, self.ctl); let size = *self .plld_sizes .entry(id.clone()) - .or_insert_with(|| crate::linked::source_size_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id, &ctl)); + .or_insert_with(|| crate::linked::source_size_in_blocks(global.iter().map(|(_, k, d)| (k, d.as_slice())), &id, &ctl)); // Without the source (missing or unreadable), it is taken as placed at 100 %. let (transform, perspective) = blocks::plld_transform(&p, size, warp.as_ref()); Placement { id, transform, perspective, warp } @@ -181,72 +243,83 @@ impl Ctx<'_> { /// Decodes a PSD layer channel, marking the document damaged if its data is unreadable. fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { rec.channel(id)?; + if self.read_error.is_some() { + return None; + } match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { Ok(p) => Some(p), Err(e) => { - self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); + self.channel_failed(name, id, &e); None } } } - /// [`Self::channel_plane`] without the warnings sink, so a layer's channels decode - /// concurrently (their RLE rows are the bulk of the open time on full-canvas layers). - fn channel_plane_decoded(&self, rec: &LayerRecord, id: i16) -> Option> { - rec.channel(id)?; - rec.decode_channel(id, self.file.header.depth, self.file.header.version).ok() - } - /// Builds a raster surface from the layer channels, leaving the surface empty if /// any present channel fails to decode rather than inventing replacement pixels. fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { let r = rec.rect; - if r.is_empty() || r.size().is_err() { + // Once the file couldn't be read the open fails: nothing more is decoded. + if r.is_empty() || r.size().is_err() || self.read_error.is_some() { return Surface::new(self.fmt); } let (w, h) = r.size().unwrap_or((0, 0)); let s = self.fmt.sample; + let (depth, version) = (self.file.header.depth, self.file.header.version); let ids: Vec = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]).collect(); - let planes: Vec>> = if cfg!(target_arch = "wasm32") { - ids.iter().map(|&id| self.channel_plane(rec, id, name)).collect() - } else { - // The channels decode independently; their RLE rows are the bulk of the open - // time for full-canvas layers, so they run side by side and the warnings land - // in the same order afterwards. - // par_map reuses the crate's worker pool (one OS thread per channel per layer - // would spawn thousands on a file with many layers). - let decoded: Vec>> = crate::pixels::par_map(ids.to_vec(), |id| self.channel_plane_decoded(rec, id)); - let mut planes: Vec>> = Vec::with_capacity(decoded.len()); - for (&id, p) in ids.iter().zip(decoded) { - if p.is_none() && rec.channel(id).is_some() { - self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty")); - } - planes.push(p); + // A decoder per channel present (a ZIP channel is inflated here, the channels side by + // side); the rows are then decoded and interleaved a band at a time straight into the + // tiles, so no buffer of the whole layer (planes or interleaved) is ever held. + // par_map reuses the crate's worker pool (one OS thread per channel per layer would + // spawn thousands on a file with many layers). + let decoders: Vec>>> = + crate::pixels::par_map(ids.to_vec(), |id| rec.channel(id).map(|ch| ch.row_decoder(w, h, depth, version))); + let mut failed = false; + for (&id, d) in ids.iter().zip(&decoders) { + if let Some(Err(e)) = d { + self.channel_failed(name, id, e); + failed = true; } - planes - }; + } // A channel that is in the file but could not be decoded leaves the layer empty, as the // warning says. Filling in for it would paint an opaque black layer over the document. - if ids.iter().zip(planes.iter()).any(|(&id, p)| p.is_none() && rec.channel(id).is_some()) { + if failed { return Surface::new(self.fmt); } // Only decoded channel data justifies a buffer: the decoders bound their output by the // bytes in the file, but a bare rectangle can declare 300000² pixels in a few bytes (#755). - if planes.iter().all(Option::is_none) { + if decoders.iter().all(Option::is_none) { self.warn_unreadable_data(format!("layer \"{name}\": no channel data for its {w}x{h} bounds; treated as empty")); return Surface::new(self.fmt); } - let refs: Vec> = planes.iter().map(|p| p.as_deref()).collect(); let mut fill: Vec> = vec![zero_sample(s); self.cc]; fill.push(max_sample(s)); let mut invert = vec![self.cmyk; self.cc]; invert.push(false); - let mut bytes = interleave(&refs, &fill, w * h, s, &invert); - if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true); + let mut surf = Surface::new(self.fmt); + for (b0, b1) in band_rows(r.top, h, w * (self.cc + 1) * s.bytes()) { + let planes: Vec>>> = + crate::pixels::par_map(decoders.iter().collect(), |d| d.as_ref().and_then(|d| d.as_ref().ok()).map(|d| d.rows(b0, b1))); + let mut refs: Vec> = Vec::with_capacity(planes.len()); + for (&id, p) in ids.iter().zip(&planes) { + match p { + Some(Err(e)) => { + self.channel_failed(name, id, e); + return Surface::new(self.fmt); + } + Some(Ok(p)) => refs.push(Some(p)), + None => refs.push(None), + } + } + let mut bytes = interleave(&refs, &fill, w * (b1 - b0), s, &invert); + if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true); + } + surf.write_interleaved(Rect::new(r.left, r.top + b0 as i32, r.right, r.top + b1 as i32), &bytes); } - let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes); surf.prune(); + // A white background, a solid fill's cache: one tile per value, shared across layers. + surf.share_uniform_tiles(&mut self.uniform); surf } @@ -270,6 +343,7 @@ impl Ctx<'_> { let bytes = interleave(&[Some(&plane)], &[zero_sample(s)], w * h, s, &[false]); surface.write_interleaved(Rect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); surface.prune(); + surface.share_uniform_tiles(&mut self.uniform); } let params = m.parameters; Some(LayerMask { @@ -453,69 +527,7 @@ impl Ctx<'_> { LayerContent::Raster(self.record_surface(rec, &name)) } } else if let Some(k) = smart_key { - let Placement { id, transform, perspective, warp } = match rec.block(k) { - Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name), - Some(b) => { - let (id, transform) = blocks::parse_smart(k, &b.data); - // Distort / Perspective: the fourth corner (the affine `transform` drops it). - let perspective = blocks::parse_smart_perspective(k, &b.data); - Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) } - } - None => Placement::default(), - }; - let contents_id = - if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) }; - // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). - let placed = rec.block(b"SoLd").or_else(|| rec.block(b"SoLE")).and_then(|b| crate::smart_map::parse_sold(&b.data)); - let stack = placed.as_ref().and_then(|p| p.stack.clone()).unwrap_or_default(); - let filter_mask = match &placed { - Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name), - _ => None, - }; - // Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered - // ones (`FEid`) back in through it; our smart objects hold the mixed result. - let stored = self.record_surface(rec, &name); - let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); - let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { - (Some(p), Some(m)) => { - let unfiltered = match self.unfiltered.get(&p.placed) { - Some(done) => done.clone(), - None => { - // Every distinct source is kept until the import ends, so what they add up to is - // bounded: past the limit the layer keeps its stored pixels, unmixed. - let item = self.filter_effects.iter().find(|i| i.id == p.placed); - let cost = item - .and_then(|i| i.rect.size().ok()) - .map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64)); - let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES; - let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); - if made.is_some() { - self.unfiltered_bytes += cost; - } - self.unfiltered.insert(p.placed.clone(), made.clone()); - made - } - }; - match unfiltered { - Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), - None => stored, - } - } - _ => stored, - }; - LayerContent::Smart(SmartObject { - contents_id, - source: SmartSource::Linked { path: id }, - transform, - smart_filters: stack.filters, - cache: Some(cache), - psd_raw: principal(k), - filters_enabled: stack.enabled, - filter_mask, - warp, - stack_mode: None, - perspective, - }) + self.smart_content(rec, &name, k, &principal) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { // A pattern-filled shape is a pattern fill layer seen through its vector mask (soft // or not). Unless that mask is soft, the stored pixels stay its rendering (a soft @@ -583,6 +595,71 @@ impl Ctx<'_> { l } + fn smart_content(&mut self, rec: &LayerRecord, name: &str, k: &[u8; 4], principal: &impl Fn(&[u8; 4]) -> Option>>) -> LayerContent { + let Placement { id, transform, perspective, warp } = match rec.block(k) { + Some(b) if k == b"PlLd" => self.plld_placement(&b.data, name), + Some(b) => { + let (id, transform) = blocks::parse_smart(k, &b.data); + // Distort / Perspective: the fourth corner (the affine `transform` drops it). + let perspective = blocks::parse_smart_perspective(k, &b.data); + Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) } + } + None => Placement::default(), + }; + let contents_id = if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) }; + // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). + let placed = rec.block(b"SoLd").or_else(|| rec.block(b"SoLE")).and_then(|b| crate::smart_map::parse_sold(&b.data)); + let stack = placed.as_ref().and_then(|p| p.stack.clone()).unwrap_or_default(); + let filter_mask = match &placed { + Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, name), + _ => None, + }; + // Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered + // ones (`FEid`) back in through it; our smart objects hold the mixed result. + let stored = self.record_surface(rec, name); + let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); + let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { + (Some(p), Some(m)) => { + let unfiltered = match self.unfiltered.get(&p.placed) { + Some(done) => done.clone(), + None => { + // Every distinct source is kept until the import ends, so what they add up to is + // bounded: past the limit the layer keeps its stored pixels, unmixed. + let item = self.filter_effects.iter().find(|i| i.id == p.placed); + let cost = item + .and_then(|i| i.rect.size().ok()) + .map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64)); + let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES; + let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + if made.is_some() { + self.unfiltered_bytes += cost; + } + self.unfiltered.insert(p.placed.clone(), made.clone()); + made + } + }; + match unfiltered { + Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), + None => stored, + } + } + _ => stored, + }; + LayerContent::Smart(SmartObject { + contents_id, + source: SmartSource::Linked { path: id }, + transform, + smart_filters: stack.filters, + cache: Some(cache), + psd_raw: principal(k), + filters_enabled: stack.enabled, + filter_mask, + warp, + stack_mode: None, + perspective, + }) + } + /// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop /// rendered from the vector data (flag bit 3, no separate real mask) is dropped: the /// compositor rasterizes the vector mask itself. @@ -633,7 +710,9 @@ impl Ctx<'_> { let sd = rec.section_divider(); let expanded = sd.is_none_or(|s| s.kind != photocraft_psd::SectionType::ClosedFolder); let artboard = crate::comps_map::ARTBOARD_KEYS.iter().find_map(|k| rec.block(k)).and_then(|b| crate::comps_map::parse_artboard(&b.data)); - let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded, artboard })); + // A Photoshop Frame's stroke: `vstk` on the frame group, in a shape's layout. + let frame_stroke = rec.block(b"vstk").and_then(|b| crate::vector_map::parse_vstk(&b.data, self.dpi)).and_then(|v| v.stroke); + let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded, artboard, frame_stroke })); l.psd_blocks = preserved_blocks(rec); self.apply_common(&mut l, rec, sd.and_then(|s| s.blend_mode)); self.apply_vector_mask(&mut l, rec); @@ -883,6 +962,28 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) psd_to_document_decoded(file, ctl, None) } +/// [`psd_to_document_decoded_integrity`] taking the file: its global blocks move into the +/// document rather than being copied (an embedded smart object's `lnk2` block can be most of a +/// file). The file's channel data may still be in its source ([`PsdFile::from_source`]): the +/// fourth value is a failure to read it, which fails the open (a layer is never left empty +/// because its file became unreadable while it opened). +pub(crate) fn psd_to_document_owned( + mut file: PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, +) -> Option<(Document, Vec, bool, Option)> { + let global = file + .global_blocks + .iter_mut() + .map(|b| { + // A layer block is still read by `unreadable_layer_block` (to say why it failed). + let data = if matches!(&b.key, b"Lr16" | b"Lr32" | b"Layr") { b.data.clone() } else { std::mem::take(&mut b.data) }; + (b.signature, b.key, Arc::new(data)) + }) + .collect(); + psd_to_document_global(&file, ctl, decoded, global) +} + /// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to /// 0.16.0) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which /// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three @@ -962,6 +1063,77 @@ pub(crate) fn can_recover_layers(file: &PsdFile) -> bool { matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty() } +fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Document, layered: bool, multichannel: bool, warnings: &mut Vec) -> bool { + let header = &file.header; + for resource in &file.resources { + match resource.id { + ids::RESOLUTION_INFO => { + if let Ok(info) = photocraft_psd::ResolutionInfo::from_bytes(&resource.data) { + let (x, y) = (info.h_res(), info.v_res()); + doc.resolution_dpi = x as f32; + warnings.extend(crate::unequal_resolution_warning(x, y)); + } + } + ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(resource.data.clone())), + ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&resource.data).into()), + ids::EXIF => doc.metadata.exif = Some(Arc::new(resource.data.clone())), + 1032 => parse_guides(&resource.data, doc), + ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => { + if let Some(bytes) = resource.data.get(..4) { + let value = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f32; + if resource.id == ids::GLOBAL_ANGLE { + doc.global_light.angle = value; + } else { + doc.global_light.altitude = value; + } + } + } + id if crate::vector_map::SAVED_PATHS.contains(&id) => match crate::vector_map::path_from_resource(&resource.data, header.width, header.height) { + Some(path) => doc.paths.push(photocraft_doc::NamedPath { + name: String::from_utf8_lossy(&resource.name).into_owned(), + path, + psd_raw: Some(Arc::new(resource.data.clone())), + }), + None => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&resource.name).into_owned(), Arc::new(resource.data.clone()))), + }, + crate::vector_map::WORK_PATH => doc.work_path = crate::vector_map::path_from_resource(&resource.data, header.width, header.height), + crate::vector_map::CLIPPING_PATH => { + let len = usize::from(resource.data.first().copied().unwrap_or(0)); + let name = resource.data.get(1..1 + len).map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); + doc.metadata.psd_resources.push((resource.id, String::from_utf8_lossy(&resource.name).into_owned(), Arc::new(resource.data.clone()))); + } + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} + id if MAPPED_RESOURCES.contains(&id) => {} + id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&resource.name).into_owned(), Arc::new(resource.data.clone()))), + } + } + crate::guides_map::apply_colors(doc); + doc.metadata.psd_global_blocks.extend(global.iter().cloned()); + // A 16/32-bit file whose layer block can't be parsed would otherwise open as a flattened + // file without a word (#2700). The raw block is kept, but a PSD save writes the document's + // own layers in its place (see `psd_export`). + let mut damaged = false; + if let Some((block, error)) = file.unreadable_layer_block() { + damaged = true; + warnings.push(format!( + "the layers ({} block) could not be read ({error}); only the merged image was opened. Saving as PSD replaces the unreadable layer data", + block.key_str() + )); + } + doc.patterns = crate::pattern_map::from_global_blocks(doc); + warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); + doc.notes = crate::annotations_map::notes_from_blocks(doc); + if crate::annotations_map::anno_unreadable(doc) { + warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); + } + if let Some(scale) = crate::annotations_map::raw_scale(doc) { + doc.measurement.scale = scale; + } + let _ = layered; + damaged +} + /// [`psd_to_document_with`] with the merged image already decoded (by /// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again. pub(crate) fn psd_to_document_decoded( @@ -980,107 +1152,39 @@ pub(crate) fn psd_to_document_decoded_integrity( ctl: &photocraft_raster::Interrupt, decoded: Option>>, ) -> Option<(Document, Vec, bool)> { + let global = file.global_blocks.iter().map(|b| (b.signature, b.key, Arc::new(b.data.clone()))).collect(); + psd_to_document_global(file, ctl, decoded, global).map(|(doc, warnings, damaged, _)| (doc, warnings, damaged)) +} + +fn psd_to_document_global( + file: &PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, + global: Vec, +) -> Option<(Document, Vec, bool, Option)> { + let (mode, layered, multichannel, depth) = import_format(file); let h = &file.header; let mut warnings = Vec::new(); - let mode = doc_mode(h.color_mode).unwrap_or_else(|| { + if doc_mode(h.color_mode).is_none() { warnings.push(format!("unknown color mode {}; importing as RGB", h.color_mode.as_u16())); - ColorMode::Rgb - }); - let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1; - let multichannel = mode == ColorMode::Multichannel && h.depth != 1; - let depth = if layered || multichannel { sample_for_depth(h.depth) } else { SampleType::U8 }; + } let mut doc = Document::new("Untitled", Size::new(h.width, h.height), mode, depth); - - // Resources. - for r in &file.resources { - match r.id { - ids::RESOLUTION_INFO => { - if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) { - // Fixed 16.16 hRes/vRes are pixels per inch. The unit shorts only - // say how to show that number (1 = px/in, 2 = px/cm). - let (x, y) = (ri.h_res(), ri.v_res()); - doc.resolution_dpi = x as f32; - warnings.extend(crate::unequal_resolution_warning(x, y)); - } - } - ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())), - ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into()), - ids::EXIF => doc.metadata.exif = Some(Arc::new(r.data.clone())), - 1032 => parse_guides(&r.data, &mut doc), - ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => { - if let Some(b) = r.data.get(..4) { - let v = i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as f32; - if r.id == ids::GLOBAL_ANGLE { - doc.global_light.angle = v; - } else { - doc.global_light.altitude = v; - } - } - } - id if crate::vector_map::SAVED_PATHS.contains(&id) => match crate::vector_map::path_from_resource(&r.data, h.width, h.height) { - Some(path) => doc.paths.push(photocraft_doc::NamedPath { - name: String::from_utf8_lossy(&r.name).into_owned(), - path, - psd_raw: Some(Arc::new(r.data.clone())), - }), - None => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), - }, - crate::vector_map::WORK_PATH => { - doc.work_path = crate::vector_map::path_from_resource(&r.data, h.width, h.height); - } - crate::vector_map::CLIPPING_PATH => { - // Kept raw (layout beyond the name is not modelled); the name is exposed. - let n = usize::from(r.data.first().copied().unwrap_or(0)); - let name = r.data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); - doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); - doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); - } - // Resource 1053 is written afresh with the extra channels on every save, so a kept copy - // would be new metadata after each round trip. Multichannel has no such rewrite: the - // imported bytes are the channel ids, copied back while they still match. - crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} - id if MAPPED_RESOURCES.contains(&id) => {} - id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), - } - } - crate::guides_map::apply_colors(&mut doc); - for b in &file.global_blocks { - doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); - } - // A 16/32-bit file whose layer block can't be parsed would otherwise open as a flattened - // file without a word (#2700). The raw block is kept, but a PSD save writes the document's - // own layers in its place (see `psd_export`). - let mut damaged = false; - if let Some((b, e)) = file.unreadable_layer_block() { - damaged = true; - warnings.push(format!( - "the layers ({} block) could not be read ({e}); only the merged image was opened. Saving as PSD replaces the unreadable layer data", - b.key_str() - )); - } - doc.patterns = crate::pattern_map::from_global_blocks(&doc); - warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); - // Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export. - doc.notes = crate::annotations_map::notes_from_blocks(&doc); - if crate::annotations_map::anno_unreadable(&doc) { - warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); - } - if let Some(scale) = crate::annotations_map::raw_scale(&doc) { - doc.measurement.scale = scale; - } + let damaged = read_initial_metadata(file, &global, &mut doc, layered, multichannel, &mut warnings); let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let mut cx = Ctx { file, + global: global.clone(), fmt, mask_fmt: PixelFormat::new(ColorMode::Grayscale, depth, false), cc, cmyk: fmt.mode == ColorMode::Cmyk, warnings, damaged, + read_error: None, dpi: doc.resolution_dpi, - txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), + txt2: global.iter().find(|(_, k, _)| k == b"Txt2").and_then(|(_, _, d)| photocraft_text::psd::parse_txt2(d)), filter_effects: Vec::new(), filter_masks: Default::default(), unfiltered: Default::default(), @@ -1089,16 +1193,17 @@ pub(crate) fn psd_to_document_decoded_integrity( plld_sizes: Default::default(), ctl: *ctl, done: 0, + uniform: Default::default(), total: file.layers().len(), }; - for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) { + for (_, key, data) in cx.global.clone().iter().filter(|(_, key, _)| matches!(key, b"FEid" | b"FXid")) { // Smart-filter caches can be large: a cancelled open stops between blocks. if ctl.cancelled() { return None; } - match photocraft_psd::filter_effects::FilterEffects::parse(&b.data) { + match photocraft_psd::filter_effects::FilterEffects::parse(data) { Ok(fx) => cx.filter_effects.extend(fx.items), - Err(e) => cx.warn(format!("smart filter masks ({}) could not be read: {e}", b.key_str())), + Err(e) => cx.warn(format!("smart filter masks ({}) could not be read: {e}", String::from_utf8_lossy(key))), } } if ctl.cancelled() { @@ -1110,227 +1215,239 @@ pub(crate) fn psd_to_document_decoded_integrity( // budget while `w * hh` does not fit a 32-bit `usize` (wasm32). let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh)); let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); - // The merged composite is decoded only when something consumes it: a flattened or - // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour - // ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly - // half the file's bytes, decoded and thrown away on every open before this. - let merged = if merged_needed(file) { - let m = decoded.unwrap_or_else(|| file.decode_merged()); - if ctl.cancelled() { - return None; - } - ctl.progress(0.1); - if let Err(e) = &m { - cx.warn_unreadable_data(format!("merged image could not be decoded: {e}")); - if can_recover_layers(file) { - cx.warn_unreadable_data("extra alpha/spot channels in the merged image could not be recovered"); - } - } - Some(m) - } else { - if let Some(Err(e)) = decoded { - cx.warn_unreadable_data(format!("merged image could not be decoded; opened the available layers instead: {e}")); - } - None - }; + let merged = decode_merged(file, decoded, ctl, &mut cx)?; + import_decoded_content(DecodedImport { file, merged: &merged, sizes, canvas, layered, multichannel, depth, fmt }, &mut doc, &mut cx); + finish_import(doc, cx, ctl) +} - if multichannel { - // Ink channels only (no layers); see `multichannel_map`. - if let Some(Ok(all)) = &merged { - let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); - crate::multichannel_map::import(file, all, &mut doc, &names, &mut cx.warnings); - } - if !file.layers().is_empty() { - cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len())); - } - } else if layered { - // Capped like `build` (#2940): a group past the document's nesting cap keeps no children, - // so no file can make this tree, or anything walking or dropping it, recurse deeper. - let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH); - // Colours stored in the document's CMYK space (Gradient Map stops…) convert through - // its own profile, as the compositor and the PSD writer read them. - let space = photocraft_compose::cmyk_space(&doc); - doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); - // Photoshop's Background: the bottom record stored without a transparency channel (-1), - // locked whatever its (localized) name and whether or not the file carries an `lspf` - // block. Its name is kept. - mark_background(file, &mut doc.layers); - // Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked). - if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = - file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) - { - apply_link_groups(&tree, &mut doc.layers, &groups); - } - if doc.layers.is_empty() - && let Some(Ok(all)) = &merged - // Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes. - && let Some((n, plane)) = sizes - { - // Flattened file: the merged image becomes the background layer. - let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; - let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); - planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); - let mut fill = vec![zero_sample(depth); cc]; - fill.push(max_sample(depth)); - let mut inv = vec![cx.cmyk; cc]; - inv.push(false); - let mut bytes = interleave(&planes, &fill, n, depth, &inv); - if fmt.mode == ColorMode::Lab && depth == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, cc + 1, true); - } - let mut s = Surface::from_interleaved(fmt, canvas, &bytes); - if alpha_idx.is_some() { - // Undo Photoshop's white matting of the merged image. - // A band of tile rows at a time (no full-size float copy of the image). - let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); - let mut vals = Vec::new(); - let mut y = canvas.y0; - while y < canvas.y1 { - let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); - s.read_region_into(band, &mut vals); - for px in vals.chunks_exact_mut(cc + 1) { - let a = px[cc]; - for c in 0..cc { - px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; - } - } - s.write_region(band, &vals); - y = band.y1; - } - } - s.prune(); - let mut bg = Layer::new("Background", LayerContent::Raster(s)); - if alpha_idx.is_none() { - bg.locks.transparency = true; - bg.locks.position = true; - } - doc.layers.push(bg); - } - } else { - if !file.layers().is_empty() { - cx.warn(format!("{:?} documents are imported flattened: {} layer records were not imported", h.color_mode, file.layers().len())); - } else { - cx.warn(format!("{:?} {}-bit document converted to {:?} 8-bit for editing", h.color_mode, h.depth, fmt.mode)); - } - if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 { - // Planar palette: 256 reds, 256 greens, 256 blues (the Color Table). - let m = &file.color_mode_data; - // Resource 1046 says how many entries are used, 1047 which one is transparent. - let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]])); - let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256); - let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); - let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8); - doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); - } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { - // The inks (`duotone_map`); the block is also kept raw, written back while they stay - // unedited. An ink whose colour is unknown (a colour book without its Lab equivalent) - // would show black and darken the image: the grey plate is shown instead, as before. - let mut unknown = false; - let alternate = file.resource(crate::duotone_map::ALTERNATE_DUOTONE_COLORS).map(|r| r.data.as_slice()); - let inks = crate::duotone_map::parse(&file.color_mode_data, alternate, &mut unknown).filter(|_| !unknown); - if inks.is_none() { - cx.warn("duotone inks are not interpreted (shown as grayscale)"); - } - let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]); - doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.as_slice().into()) }); - } else if !file.color_mode_data.is_empty() { - cx.warn("color mode data is not preserved"); - } - match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { - Ok(mut img) => { - // Resource 1047: pixels of the transparent entry are transparent. - if h.color_mode == PsdMode::Indexed - && let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent) - && let Some(Ok(all)) = &merged - && let Some(indices) = sizes.and_then(|(_, plane)| merged_plane(all, 0, plane)) - { - for (px, &i) in img.data.as_chunks_mut::<4>().0.iter_mut().zip(indices) { - if i == t { - px[3] = 0; - } - } - } - let mut s = Surface::new(fmt); - write_rgba8(&mut s, canvas, &img.data); - drop(img); - s.prune(); - doc.layers.push(flat_background(s)); - } - // A merged image that failed to decode was reported above. - Err(_) if matches!(merged, Some(Err(_))) => {} - Err(e) => cx.warn_unreadable_data(format!("the image could not be read: {e}")), - } - } - - // Extra (alpha / spot) channels of the merged image. - if layered - && let Some(Ok(all)) = &merged - && let Some((n, plane)) = sizes - { - let first = cc + usize::from(file.merged_has_alpha()); - let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); - // Photoshop lists every extra channel, the merged transparency first when there is one. - let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); - let names = names.get(skip..).unwrap_or_default(); - // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records - // the channel options are read from below, the transparency's first when `skip` is 1. - // A file from an earlier PhotoCraft stores them straight and is read as it was written, - // past the channels of the Photoshop file it opened, if any. - let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX }; - let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::spot_flags(&r.data, true, skip) - } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::spot_flags(&r.data, false, skip) - } else { - Vec::new() - }; - for (k, idx) in (first..usize::from(h.channels)).enumerate() { - let Some(p) = merged_plane(all, idx, plane) else { - cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported")); - break; - }; - let mut raw = be_to_ne_plane(p, depth, n); - if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) { - crate::channel_map::photoshop_spot_plane(&mut raw, depth); - } - let mut s = Surface::new(cx.mask_fmt); - s.write_interleaved(canvas, &raw); - s.prune(); - let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); - doc.channels.push(AlphaChannel::new(name, s)); - } - // Channel options (overlay colour, opacity, colour indicates) and spot inks. - if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels); - } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels); - } - // Saved in Quick Mask mode: that channel becomes the document's Quick Mask again. - if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO) - && r.data.len() >= 2 - && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) - && k < doc.channels.len() - { - doc.quick_mask = Some(doc.channels.remove(k)); - } - } - - // Layer comps (resource 1065 + per-layer `cmls`); the raw data stays for verbatim export. - let raw_comps = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == crate::comps_map::LAYER_COMPS).map(|(_, _, d)| d.clone()); - let comp_warnings; - (doc.layer_comps, doc.last_applied_comp, doc.last_document_state, comp_warnings) = - crate::comps_map::decode_comps(raw_comps.as_deref().map(Vec::as_slice), &doc); - cx.warnings.extend(comp_warnings); - // Slices (resource 1050), after layer ids are known; the raw data stays for verbatim export. +fn finish_import(mut doc: Document, mut cx: Ctx<'_>, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec, bool, Option)> { + let raw_comps = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == crate::comps_map::LAYER_COMPS).map(|(_, _, data)| data.clone()); + let (comps, applied, state, warnings) = crate::comps_map::decode_comps(raw_comps.as_deref().map(Vec::as_slice), &doc); + (doc.layer_comps, doc.last_applied_comp, doc.last_document_state) = (comps, applied, state); + cx.warnings.extend(warnings); crate::slices_map::import(&mut doc); - // Character and paragraph styles from the type layers' engine data. cx.warnings.extend(crate::text_styles_map::import(&mut doc)); - if ctl.cancelled() { return None; } ctl.progress(1.0); - Some((doc, cx.warnings, cx.damaged)) + Some((doc, cx.warnings, cx.damaged, cx.read_error)) +} + +fn decode_merged( + file: &PsdFile, + decoded: Option>>, + ctl: &photocraft_raster::Interrupt, + cx: &mut Ctx<'_>, +) -> Option>>> { + if merged_needed(file) { + let merged = decoded.unwrap_or_else(|| file.decode_merged()); + if ctl.cancelled() { + return None; + } + ctl.progress(0.1); + if let Err(error) = &merged { + cx.warn_unreadable_data(format!("merged image could not be decoded: {error}")); + if can_recover_layers(file) { + cx.warn_unreadable_data("extra alpha/spot channels in the merged image could not be recovered"); + } + } + Some(Some(merged)) + } else { + if let Some(Err(error)) = decoded { + cx.warn_unreadable_data(format!("merged image could not be decoded; opened the available layers instead: {error}")); + } + Some(None) + } +} + +fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_>) { + if import.multichannel { + import_multichannel(import.file, import.merged, doc, cx); + } else if import.layered { + import_layered(import, doc, cx); + } else { + import_flattened(import.file, import.merged, import.sizes, import.canvas, import.fmt, doc, cx); + } + import_extra_channels(import, doc, cx); +} + +fn import_multichannel(file: &PsdFile, merged: &Option>>, doc: &mut Document, cx: &mut Ctx<'_>) { + if let Some(Ok(all)) = merged { + let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings); + } + if !file.layers().is_empty() { + cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len())); + } +} + +fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_>) { + let DecodedImport { file, merged, sizes, canvas, depth, fmt, .. } = import; + let cc = fmt.mode.color_channels(); + let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH); + let space = photocraft_compose::cmyk_space(doc); + doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); + mark_background(file, &mut doc.layers); + if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = + file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) + { + apply_link_groups(&tree, &mut doc.layers, &groups); + } + let Some((n, plane)) = sizes else { return }; + let Some(Ok(all)) = merged else { return }; + if !doc.layers.is_empty() { + return; + } + let alpha_idx = file.merged_has_alpha().then_some(cc); + let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); + planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); + let mut fill = vec![zero_sample(depth); cc]; + fill.push(max_sample(depth)); + let mut inv = vec![cx.cmyk; cc]; + inv.push(false); + let mut bytes = interleave(&planes, &fill, n, depth, &inv); + if fmt.mode == ColorMode::Lab && depth == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, cc + 1, true); + } + let mut surface = Surface::from_interleaved(fmt, canvas, &bytes); + if alpha_idx.is_some() { + let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + surface.read_region_into(band, &mut vals); + for px in vals.chunks_exact_mut(cc + 1) { + let a = px[cc]; + for c in 0..cc { + px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; + } + } + surface.write_region(band, &vals); + y = band.y1; + } + } + surface.prune(); + let mut bg = Layer::new("Background", LayerContent::Raster(surface)); + if alpha_idx.is_none() { + bg.locks.transparency = true; + bg.locks.position = true; + } + doc.layers.push(bg); +} + +fn import_flattened( + file: &PsdFile, + merged: &Option>>, + sizes: Option<(usize, usize)>, + canvas: Rect, + fmt: PixelFormat, + doc: &mut Document, + cx: &mut Ctx<'_>, +) { + let h = &file.header; + if !file.layers().is_empty() { + cx.warn(format!("{:?} documents are imported flattened: {} layer records were not imported", h.color_mode, file.layers().len())); + } else { + cx.warn(format!("{:?} {}-bit document converted to {:?} 8-bit for editing", h.color_mode, h.depth, fmt.mode)); + } + if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 { + let m = &file.color_mode_data; + let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]])); + let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256); + let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); + let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8); + doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); + } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { + let mut unknown = false; + let alternate = file.resource(crate::duotone_map::ALTERNATE_DUOTONE_COLORS).map(|r| r.data.as_slice()); + let inks = crate::duotone_map::parse(&file.color_mode_data, alternate, &mut unknown).filter(|_| !unknown); + if inks.is_none() { + cx.warn("duotone inks are not interpreted (shown as grayscale)"); + } + let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]); + doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.as_slice().into()) }); + } else if !file.color_mode_data.is_empty() { + cx.warn("color mode data is not preserved"); + } + match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + Ok(mut img) => { + if h.color_mode == PsdMode::Indexed + && let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent) + && let Some(Ok(all)) = merged + && let Some(indices) = sizes.and_then(|(_, plane)| merged_plane(all, 0, plane)) + { + for (px, &i) in img.data.as_chunks_mut::<4>().0.iter_mut().zip(indices) { + if i == t { + px[3] = 0; + } + } + } + let mut surface = Surface::new(fmt); + write_rgba8(&mut surface, canvas, &img.data); + drop(img); + surface.prune(); + doc.layers.push(flat_background(surface)); + } + Err(_) if matches!(merged, Some(Err(_))) => {} + Err(error) => cx.warn_unreadable_data(format!("the image could not be read: {error}")), + } +} + +fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_>) { + // Extra (alpha / spot) channels of the merged image. + if !import.layered { + return; + } + let DecodedImport { file, merged, sizes, canvas, depth, .. } = import; + let Some(Ok(all)) = merged else { return }; + let Some((n, plane)) = sizes else { return }; + let cc = cx.cc; + let first = cc + usize::from(file.merged_has_alpha()); + let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + // Photoshop lists every extra channel, the merged transparency first when there is one. + let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(file.header.channels).saturating_sub(cc)); + let names = names.get(skip..).unwrap_or_default(); + // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records + // the channel options are read from below, the transparency's first when `skip` is 1. + // A file from an earlier PhotoCraft stores them straight as it was written, + // past the channels of the Photoshop file it opened, if any. + let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX }; + let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, skip) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, skip) + } else { + Vec::new() + }; + for (k, idx) in (first..usize::from(file.header.channels)).enumerate() { + let Some(p) = merged_plane(all, idx, plane) else { + cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported")); + break; + }; + let mut raw = be_to_ne_plane(p, depth, n); + if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) { + crate::channel_map::photoshop_spot_plane(&mut raw, depth); + } + let mut s = Surface::new(cx.mask_fmt); + s.write_interleaved(canvas, &raw); + s.prune(); + let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); + doc.channels.push(AlphaChannel::new(name, s)); + } + if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels); + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels); + } + if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) + && k < doc.channels.len() + { + doc.quick_mask = Some(doc.channels.remove(k)); + } } #[cfg(test)] diff --git a/crates/io/src/psd_import/tests.rs b/crates/io/src/psd_import/tests.rs index 66c3bb3cdd..3bb19e3ef8 100644 --- a/crates/io/src/psd_import/tests.rs +++ b/crates/io/src/psd_import/tests.rs @@ -367,3 +367,24 @@ fn a_complete_alpha_identifiers_resource_keeps_photoshop_polarity() { assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's"); assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted"); } + +#[test] +fn layer_bands_end_on_tile_rows_and_cover_the_layer() { + let t = TILE_SIZE as usize; + for (top, h, row_bytes) in [(0, 2000, 8000), (-37, 15000, 80_000), (100, 5, 4), (255, 1, 4), (-256, 40_000, 1 << 20), (7, 0, 4)] { + let bands = band_rows(top, h, row_bytes); + let mut next = 0; + for &(a, b) in &bands { + assert_eq!(a, next); + assert!(b > a); + if b < h { + assert_eq!((top + b as i32).rem_euclid(TILE_SIZE), 0, "{top} {h}: band ends inside a tile row"); + assert!((b - a) * row_bytes <= BAND_BYTES + t * row_bytes || b - a <= t); + } + next = b; + } + assert_eq!(next, h); + } + // A small layer is one band. + assert_eq!(band_rows(0, 2000, 8000), vec![(0, 2000)]); +} diff --git a/crates/io/src/smart_map/export.rs b/crates/io/src/smart_map/export.rs index ab1340fa17..e057a7032f 100644 --- a/crates/io/src/smart_map/export.rs +++ b/crates/io/src/smart_map/export.rs @@ -5,7 +5,10 @@ use super::import::{ colour_rgbc, enum_param, from_hex, gallery_item, known_by_command, pf_range, pi_range, seed_param, }; use super::placed::enumv; -use super::*; +use super::{ + Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat, + perspective_warp, +}; // ---------- writing ---------- @@ -111,7 +114,8 @@ fn enum_out(p: &J, key: &str, default: &str, type_id: &str, table: &[(&str, &str Ok(enumv(type_id, psd)) } -/// `key` rounded to a `long` in `lo..=hi`. +/// `key` rounded to a `long` in `lo..=hi` (clamped, and a non-number is the default, as the +/// engine reads Extrude, Tiles, Trace Contour, Crystallize and Pointillize). fn pi_in(p: &J, key: &str, default: f64, lo: i32, hi: i32) -> Value { Value::Integer(pf(p, key, default).round().clamp(f64::from(lo), f64::from(hi)) as i32) } diff --git a/crates/io/src/smart_map/halftone_tests.rs b/crates/io/src/smart_map/halftone_tests.rs index beae1725a7..d4f67dbfec 100644 --- a/crates/io/src/smart_map/halftone_tests.rs +++ b/crates/io/src/smart_map/halftone_tests.rs @@ -1,4 +1,5 @@ -//! Halftone Pattern descriptors measured in Photoshop 27.11.0 (#8940). +//! Halftone Pattern descriptors with the keys from the reference application's Filter Gallery plug-in terminology (#8940): +//! `HlSz` size, `Cntr` contrast and a `ScrT` enum (`ScrD` dot, `ScrC` circle, `ScrL` line). use super::import::{GALLERY_CLASS, GALLERY_COMMAND, colour_rgbc}; use super::*; @@ -16,13 +17,13 @@ fn filter(pattern: &str, size: i32, contrast: i32) -> SmartFilter { } } -/// Builds the inline Halftone descriptor measured in Photoshop. +/// Builds the inline Halftone descriptor as the reference application writes it. fn native_effect(pattern: &str, size: i32, contrast: i32) -> Descriptor { let mut d = Descriptor::new("GEfc") .with("GEfk", enumv("GEft", "HlfS")) - .with("Sz ", Value::Integer(size)) + .with("HlSz", Value::Integer(size)) .with("Cntr", Value::Integer(contrast)) - .with("Ptrn", enumv("HlfT", pattern)); + .with("ScrT", enumv("ScrT", pattern)); d.name = UnicodeString::new_nul("Filter Gallery"); d } @@ -41,10 +42,10 @@ fn native_item(effect: Descriptor) -> Descriptor { .with("filterID", Value::Integer(code(b"GEfc"))) } -/// Checks the measured pattern enums and editable settings in both mapping directions. +/// Checks the pattern enums and editable settings in both mapping directions. #[test] fn halftone_pattern_reads_and_writes_photoshop_inline_descriptors() { - for (pattern, native) in [("dot", "Dt "), ("circle", "Crc "), ("line", "Ln ")] { + for (pattern, native) in [("dot", "ScrD"), ("circle", "ScrC"), ("line", "ScrL")] { for (size, contrast) in [(1, 0), (2, 5), (7, 31), (12, 50)] { let expected = filter(pattern, size, contrast); let native = native_item(native_effect(native, size, contrast)); @@ -72,22 +73,49 @@ fn halftone_pattern_defaults_and_mixed_hidden_stack_round_trip() { assert_eq!(filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()), f); } +/// A default Halftone Pattern as the reference application saves it (size 1, contrast 5, Dot) opens editable. +#[test] +fn halftone_pattern_photoshop_default_imports_editable() { + let f = filter_from_item(&native_item(native_effect("ScrD", 1, 5))); + assert_eq!(f.command, GALLERY_COMMAND); + assert_eq!(f.params["effects"][0]["filter"], json!("halftonePattern")); + assert_eq!(f.params["effects"][0]["params"], json!({"size": 1, "contrast": 5, "patternType": "dot"})); +} + +/// Keys the reference application's Filter Gallery doesn't use (`Sz `, `Ptrn` of type `HlfT`) are not taken for +/// Halftone settings: the item stays opaque and is written back as it was read. +#[test] +fn halftone_pattern_foreign_keys_stay_verbatim() { + for (pattern, shape) in [("Dt ", "dot"), ("Crc ", "circle"), ("Ln ", "line")] { + let mut d = Descriptor::new("GEfc") + .with("GEfk", enumv("GEft", "HlfS")) + .with("Sz ", Value::Integer(2)) + .with("Cntr", Value::Integer(5)) + .with("Ptrn", enumv("HlfT", pattern)); + d.name = UnicodeString::new_nul("Filter Gallery"); + let item = native_item(d); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{shape}"); + assert_eq!(item_for_filter(&f).unwrap(), item, "{shape}"); + } +} + /// Preserves unfamiliar and malformed settings instead of substituting editable defaults. #[test] fn halftone_pattern_unknown_or_malformed_descriptors_stay_verbatim() { - let valid = native_effect("Dt ", 2, 5); + let valid = native_effect("ScrD", 2, 5); let mut invalid = vec![ valid.clone().with("futureSetting", Value::Integer(1)), - valid.clone().with("Sz ", Value::Integer(3)), // repeated key + valid.clone().with("HlSz", Value::Integer(3)), // repeated key ]; for (key, value) in [ ("GEfk", enumv("wrong", "HlfS")), - ("Ptrn", enumv("HlfT", "????")), - ("Ptrn", enumv("wrong", "Dt ")), - ("Ptrn", Value::Integer(0)), - ("Sz ", Value::Integer(0)), - ("Sz ", Value::Integer(13)), - ("Sz ", Value::Double(2.0)), + ("ScrT", enumv("ScrT", "????")), + ("ScrT", enumv("wrong", "ScrD")), + ("ScrT", Value::Integer(0)), + ("HlSz", Value::Integer(0)), + ("HlSz", Value::Integer(13)), + ("HlSz", Value::Double(2.0)), ("Cntr", Value::Integer(-1)), ("Cntr", Value::Integer(51)), ("GELv", Value::Integer(1)), @@ -97,7 +125,7 @@ fn halftone_pattern_unknown_or_malformed_descriptors_stay_verbatim() { effect.items.push((Id::new(key), value)); invalid.push(effect); } - for missing in ["Sz ", "Cntr", "Ptrn"] { + for missing in ["HlSz", "Cntr", "ScrT"] { let mut effect = valid.clone(); effect.items.retain(|(id, _)| !id.is(missing)); invalid.push(effect); @@ -113,7 +141,7 @@ fn halftone_pattern_unknown_or_malformed_descriptors_stay_verbatim() { /// Keeps a whole Gallery stack opaque when a Halftone entry has an unfamiliar class ID. #[test] fn halftone_pattern_unknown_nested_class_stays_verbatim() { - let valid = native_effect("Dt ", 2, 5); + let valid = native_effect("ScrD", 2, 5); let mut unknown = valid.clone(); unknown.class_id = Id::new("Xxxx"); for effects in [vec![unknown.clone()], vec![valid.clone(), unknown.clone()], vec![unknown, valid]] { @@ -130,7 +158,7 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() { /// which a save would otherwise drop. #[test] fn halftone_pattern_unknown_outer_setting_stays_verbatim() { - let valid = native_effect("Dt ", 2, 5); + let valid = native_effect("ScrD", 2, 5); let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7)); let item = native_item(gallery); let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap(); diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index f59cf94638..f8f3f50e3f 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -1,6 +1,9 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole}; use super::placed::enumv; -use super::*; +use super::{ + BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc, + hue_sat, json, num, perspective_warp, text_of, +}; // ---------- the filters we model ---------- @@ -464,12 +467,20 @@ const WAVE_KEYS: [&str; 10] = ["Wvtp", "NmbG", "WLMn", "WLMx", "AmMn", "AmMx", " /// Tiles its colours). pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descriptor) -> Option<(&'static str, J)> { let k = KNOWN.iter().find(|k| k.class == class)?; - let params = match (class, f) { - ("adaptCorrect" | "Invr", None) => json!({}), - (c, None) if NO_OPTIONS.contains(&c) => json!({}), - (_, None) => return None, - // Exactly the settings Photoshop writes (and we write back); an extra or repeated one keeps - // the filter verbatim rather than lose it on save. + let params = match f { + None if class == "adaptCorrect" || class == "Invr" || NO_OPTIONS.contains(&class) => json!({}), + None => return None, + Some(f) => distort_filter_params(class, f) + .or_else(|| blur_filter_params(class, f)) + .or_else(|| pixel_filter_params(class, f, item)) + .or_else(|| stylize_filter_params(class, f, item)) + .or_else(|| other_filter_params(class, f))?, + }; + Some((k.command, params)) +} + +fn distort_filter_params(class: &str, f: &Descriptor) -> Option { + Some(match (class, Some(f)) { ("Pnch", Some(f)) if exact_keys(f, &["Amnt"]) => json!({"amount": int(f, "Amnt")?}), ("Twrl", Some(f)) if exact_keys(f, &["Angl"]) => json!({"angle": int(f, "Angl")?}), ("Rple", Some(f)) if exact_keys(f, &["Amnt", "RplS"]) => json!({"amount": int(f, "Amnt")?, "size": enum_name(f, "RplS", "RplS", &RIPPLE_SIZE)?}), @@ -499,6 +510,12 @@ pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descripto "seed": (int(f, "RndS")? as u32), }) } + _ => return None, + }) +} + +fn blur_filter_params(class: &str, f: &Descriptor) -> Option { + Some(match (class, Some(f)) { ("GsnB" | "boxblur" | "Mdn " | "HghP" | "Mxm " | "Mnm ", Some(f)) => json!({"radius": px(f, "Rds ")?}), ("MtnB", Some(f)) => json!({"angle": int(f, "Angl")?, "distance": px(f, "Dstn")?}), ("UnsM", Some(f)) => json!({"amount": px(f, "Amnt")?, "radius": px(f, "Rds ")?, "threshold": int(f, "Thsh")?}), @@ -529,6 +546,12 @@ pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descripto "method": enum_name(f, "BlrM", "BlrM", &RADIAL_METHOD)?, "quality": enum_name(f, "BlrQ", "BlrQ", &RADIAL_QUALITY)?, }), + _ => return None, + }) +} + +fn pixel_filter_params(class: &str, f: &Descriptor, item: &Descriptor) -> Option { + Some(match (class, Some(f)) { ("Msc ", Some(f)) => json!({"cellSize": px(f, "ClSz")?}), ("Mztn", Some(f)) if only_keys(f, &["MztT"]) => json!({"type": enum_name(f, "MztT", "MztT", &MEZZOTINT_TYPES)?}), ("ClrH", Some(f)) => color_halftone_params(f)?, @@ -551,6 +574,12 @@ pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descripto } p } + _ => return None, + }) +} + +fn stylize_filter_params(class: &str, f: &Descriptor, item: &Descriptor) -> Option { + Some(match (class, Some(f)) { ("Wnd ", Some(f)) => json!({ "method": enum_from(f, "WndM", "WndM", &WIND_METHODS)?, "direction": enum_from(f, "Drct", "Drct", &WIND_DIRECTIONS)?, @@ -564,6 +593,12 @@ pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descripto "maskIncompleteBlocks": bool_at(f, "ExtM")?, }), ("Tls ", Some(f)) => tiles_params(f, item)?, + _ => return None, + }) +} + +fn other_filter_params(class: &str, f: &Descriptor) -> Option { + Some(match (class, Some(f)) { ("Ofst", Some(f)) if only_keys(f, &["Hrzn", "Vrtc", "Fl "]) => { json!({"horizontal": int(f, "Hrzn")?, "vertical": int(f, "Vrtc")?, "undefinedAreas": enum_from(f, "Fl ", "FlMd", &OFFSET_FILL)?}) } @@ -578,10 +613,8 @@ pub(super) fn known_params(class: &str, f: Option<&Descriptor>, item: &Descripto ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, - }; - Some((k.command, params)) + }) } - // ---------- Filter Gallery ---------- // // Filter › Filter Gallery stores its effect stack in a `GEfc` filter (filterID `GEfc`). Photoshop @@ -611,11 +644,11 @@ pub(super) struct GalleryCode { pub(super) const GALLERY_EFFECTS: [GalleryCode; 2] = [ GalleryCode { key: "chrome", code: "Chrm", settings: &[("Dtl ", "detail"), ("Smth", "smoothness")] }, - GalleryCode { key: "halftonePattern", code: "HlfS", settings: &[("Sz ", "size"), ("Cntr", "contrast")] }, + GalleryCode { key: "halftonePattern", code: "HlfS", settings: &[("HlSz", "size"), ("Cntr", "contrast")] }, ]; -// Photoshop 27.11 writes Halftone Pattern's shape as an enum, not a numeric choice index. -const HALFTONE_PATTERNS: [(&[u8; 4], &str); 3] = [(b"Dt ", "dot"), (b"Crc ", "circle"), (b"Ln ", "line")]; +// Halftone Pattern's shape is a `ScrT` enum (Filter Gallery terminology: Dot, Circle, Line). +const HALFTONE_PATTERNS: [(&[u8; 4], &str); 3] = [(b"ScrD", "dot"), (b"ScrC", "circle"), (b"ScrL", "line")]; /// An `RGBC` descriptor (0–255 channels) as straight `[r, g, b, a]`, 0–1. fn rgbc_colour(d: &Descriptor) -> Option { @@ -683,18 +716,18 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { return None; } if !e.class_id.is(GALLERY_CLASS) - || !only_keys(e, &["GEfk", "Sz ", "Cntr", "Ptrn", "GELv"]) + || !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"]) || !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft")) || e.get("GELv").is_some_and(|v| !matches!(v, Value::Boolean(_))) { return None; } - int_in(e, "Sz ", 1, 12)?; + int_in(e, "HlSz", 1, 12)?; int_in(e, "Cntr", 0, 50)?; - params.insert("patternType".into(), json!(enum_name(e, "Ptrn", "HlfT", &HALFTONE_PATTERNS)?)); + params.insert("patternType".into(), json!(enum_name(e, "ScrT", "ScrT", &HALFTONE_PATTERNS)?)); } for (k, v) in &e.items { - if k.is("GEfk") || k.is("GELv") || g.key == "halftonePattern" && k.is("Ptrn") { + if k.is("GEfk") || k.is("GELv") || g.key == "halftonePattern" && k.is("ScrT") { continue; } let (_, ours) = g.settings.iter().find(|(psd, _)| k.is(psd))?; @@ -759,9 +792,9 @@ pub(super) fn gallery_item(f: &SmartFilter) -> Result { let defaults = photocraft_algo::GalleryFilter::from_key(key).map(photocraft_algo::GalleryEffect::new); let settings: Vec<(&str, Value)> = if key == "halftonePattern" { vec![ - ("Sz ", whole(pf_range(params, "size", 1.0, 1.0, 12.0)?, (1, 12), "size")?), + ("HlSz", whole(pf_range(params, "size", 1.0, 1.0, 12.0)?, (1, 12), "size")?), ("Cntr", whole(pf_range(params, "contrast", 5.0, 0.0, 50.0)?, (0, 50), "contrast")?), - ("Ptrn", enum_param(params, "patternType", "dot", "HlfT", &HALFTONE_PATTERNS)?), + ("ScrT", enum_param(params, "patternType", "dot", "ScrT", &HALFTONE_PATTERNS)?), ] } else { g.settings.iter().map(|(psd, ours)| (*psd, pi(params, ours, defaults.as_ref().map_or(0.0, |x| f64::from(x.get(ours)))))).collect() diff --git a/crates/io/src/smart_map/placed.rs b/crates/io/src/smart_map/placed.rs index 9bdb52bc1c..20079f9840 100644 --- a/crates/io/src/smart_map/placed.rs +++ b/crates/io/src/smart_map/placed.rs @@ -1,5 +1,5 @@ use super::export::{set, set_before, unit}; -use super::*; +use super::{Affine, BezierMesh, Descriptor, Id, ObjectArray, UnicodeString, Value, VersionedDescriptor, Warp, WarpStyle, get_desc, num, text_of}; /// Everything needed to write the placed-layer blocks of a smart object. #[derive(Clone, Debug)] diff --git a/crates/io/src/smart_map/tests.rs b/crates/io/src/smart_map/tests.rs index 1bbd5bd094..22808b08a2 100644 --- a/crates/io/src/smart_map/tests.rs +++ b/crates/io/src/smart_map/tests.rs @@ -1470,7 +1470,8 @@ fn stylize_and_pixelate_filters_open_from_photoshop_shapes() { fltr("filter.stylize.diffuse", json!({"mode": "normal", "seed": 7})), Some(Value::Descriptor(Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Nrml")))) ); - // Defaults and out-of-range values are written in Photoshop's ranges. + // Defaults and out-of-range values are written as the engine renders them: clamped to + // Photoshop's ranges, and a non-number as the default. assert_eq!( fltr("filter.stylize.traceContour", json!({})), Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Lwr ")))) @@ -1478,17 +1479,28 @@ fn stylize_and_pixelate_filters_open_from_photoshop_shapes() { assert_eq!(fltr("filter.pixelate.crystallize", json!({})), Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(10))))); assert_eq!(fltr("filter.pixelate.pointillize", json!({})), Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5))))); assert_eq!( - fltr("filter.pixelate.crystallize", json!({"cellSize": 1e9})), + fltr("filter.pixelate.crystallize", json!({"cellSize": 300})), Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(300)))) ); + assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": 2.6})), Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(3))))); assert_eq!( - fltr("filter.pixelate.pointillize", json!({"cellSize": "big"})), - Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5)))) - ); - assert_eq!( - fltr("filter.stylize.traceContour", json!({"level": -40, "edge": "upper"})), + fltr("filter.stylize.traceContour", json!({"level": 0, "edge": "upper"})), Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(0)).with("Edg ", enumv("CntE", "Upr ")))) ); + assert_eq!( + fltr("filter.stylize.traceContour", json!({"level": 255.4})), + Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(255)).with("Edg ", enumv("CntE", "Lwr ")))) + ); + let crst = |v: i32| Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(v)))); + let pntl = |v: i32| Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(v)))); + let trcc = |v: i32, e: &str| Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(v)).with("Edg ", enumv("CntE", e)))); + assert_eq!(fltr("filter.pixelate.crystallize", json!({"cellSize": 1e9})), crst(300)); + assert_eq!(fltr("filter.pixelate.crystallize", json!({"cellSize": 2.4})), crst(3)); + assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": "big"})), pntl(5)); + assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": null})), pntl(5)); + assert_eq!(fltr("filter.stylize.traceContour", json!({"level": -40, "edge": "upper"})), trcc(0, "Upr ")); + assert_eq!(fltr("filter.stylize.traceContour", json!({"level": 255.5})), trcc(255, "Lwr ")); + assert_eq!(fltr("filter.stylize.traceContour", json!({"level": [128]})), trcc(128, "Lwr ")); } #[test] diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 98161732de..27b536d996 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -286,32 +286,40 @@ fn font_resolver() -> usvg::FontResolver<'static> { /// Parse options for an untrusted drawing. `` resolves only `data:` URLs: a file path /// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`, /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all -/// (`usvg` already blanks it for sub-SVGs). -fn options(fontdb: Arc) -> usvg::Options<'static> { +/// (`usvg` already blanks it for sub-SVGs). An embedded SVG may nest `depth_left` levels: it is +/// parsed on top of the drawing's own recursion, so the two depths share [`MAX_XML_DEPTH`]. +fn options(fontdb: Arc, depth_left: usize) -> usvg::Options<'static> { let default_resolver = usvg::ImageHrefResolver::default_data_resolver(); let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| { // Reject embedded images whose decoded RGBA storage would exceed the budget before // resvg (which allocates the full intrinsic image before any destination scaling) ever // sees them. Formats without a cheap header probe fall through unchanged; their decode // path is bounded elsewhere or accepted as-is, matching the previous behavior. - if let Some(probed) = embedded_image_dimensions(mime, &data) { + let probed = embedded_image_dimensions(mime, &data); + if let Some(probed) = probed { let (w, h) = probed?; let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4); if decoded > MAX_EMBEDDED_IMAGE_BYTES { return None; } } - // An embedded `image/svg+xml` value can itself be gzip-compressed (SVGZ-in-data-URL). - // The default resolver's sub-SVG loader checks for the gzip magic and decompresses - // again with no byte limit of its own. Apply our bounded inflate first: if the data is - // gzip-compressed, hand the default resolver the already-decompressed (and - // size-checked) bytes, which no longer start with the gzip magic, so it parses them - // directly instead of decompressing a second time. - if (mime == "image/svg+xml" || mime == "text/plain") && data.starts_with(&[0x1f, 0x8b]) { - return match inflate(&data) { - Ok(xml) if !xml.starts_with(&[0x1f, 0x8b]) => default_resolver("image/svg+xml", std::sync::Arc::new(xml.into_owned()), opts), - _ => None, + // An embedded SVG (`image/svg+xml`, or `text/plain` that isn't one of the raster + // formats probed above) is parsed by the default resolver with none of `parse_with`'s + // checks, so they run here. It can itself be gzip-compressed (SVGZ-in-data-URL), and the + // default resolver's sub-SVG loader would decompress again with no byte limit of its + // own: hand it our bounded inflate's output instead, which no longer starts with the + // gzip magic. Nesting deeper than `depth_left` drops the image rather than overflowing + // the parser's stack. + if mime == "image/svg+xml" || (mime == "text/plain" && probed.is_none()) { + let (mime, xml) = match inflate(&data) { + Ok(std::borrow::Cow::Borrowed(_)) => (mime, Arc::clone(&data)), + Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)), + _ => return None, }; + if nesting_depth(&xml, depth_left) > depth_left { + return None; + } + return default_resolver(mime, xml, opts); } default_resolver(mime, data, opts) }); @@ -344,13 +352,14 @@ fn parse(bytes: &[u8]) -> Result { /// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one). fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Result { let xml = inflate(bytes)?; - if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH { + let depth = nesting_depth(&xml, MAX_XML_DEPTH); + if depth > MAX_XML_DEPTH { return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep"))); } let has_text = xml.windows(4).any(|w| w == b"text"); let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) }; let fontless_text = has_text && fontdb.faces().next().is_none(); - let opt = options(fontdb); + let opt = options(fontdb, MAX_XML_DEPTH.saturating_sub(depth)); // Parse the already-bounded-and-decoded text with from_str, not from_data: from_data // re-checks the gzip magic and would decompress a second time without our size limit if // the inflated content happened to start with a nested gzip stream. @@ -367,7 +376,7 @@ fn inflate(bytes: &[u8]) -> Result, IoError> { return Ok(std::borrow::Cow::Borrowed(bytes)); } let mut out = Vec::new(); - let mut reader = flate2::read::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1)); + let mut reader = flate2::bufread::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1)); reader.read_to_end(&mut out).map_err(|e| IoError::Svg(format!("the compressed SVG couldn't be inflated: {e}")))?; if u64::try_from(out.len()).unwrap_or(u64::MAX) > MAX_INFLATED_BYTES { return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", MAX_INFLATED_BYTES >> 20))); @@ -379,7 +388,7 @@ fn inflate(bytes: &[u8]) -> Result, IoError> { /// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add /// nothing; quoted attribute values may hold `>` or `/>`. Malformed markup is only counted /// loosely: the parser rejects it afterwards. -fn nesting_depth(xml: &[u8], limit: usize) -> usize { +pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { let after = |from: usize, pat: &[u8]| -> usize { xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len())) }; @@ -498,14 +507,47 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pub fn rasterize(bytes: &[u8], scale: f32) -> Result { let tree = parse(bytes)?.tree; let cv = canvas(&tree, &mut Vec::new()); - let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0); + let pixmap = render(&tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?; + let rect = Rect::new(0, 0, pixmap.width() as i32, pixmap.height() as i32); + let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect(); + Ok(photocraft_compose::Buffer { rect, px }) +} + +/// The whole drawing at one pixel per SVG unit, scaled down to fit [`MAX_SIDE`] a side and +/// [`MAX_RASTER_PIXELS`] in all: its width, height and straight-alpha RGBA8 pixels. Paste uses it +/// for a drawing copied as SVG markup. +pub fn rasterize_rgba8(bytes: &[u8]) -> Result<(u32, u32, Vec), IoError> { + let tree = parse(bytes)?.tree; + let cv = canvas(&tree, &mut Vec::new()); + let (w, h) = (f64::from(tree.size().width() * cv.scale), f64::from(tree.size().height() * cv.scale)); + // The largest k ≤ 1 with (w·k + 2)(h·k + 2) ≤ the limit: `render` rounds each side up, and + // f32 rounding can add one more pixel. + let max = MAX_RASTER_PIXELS as f64; + let fit = if (w + 2.0) * (h + 2.0) <= max { + 1.0 + } else { + let (a, b) = (w * h, 2.0 * (w + h)); + (-b + (b * b - 4.0 * a * (4.0 - max)).sqrt()) / (2.0 * a) + }; + let pixmap = render(&tree, cv.scale * fit as f32)?; + let px = pixmap + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) + .collect(); + Ok((pixmap.width(), pixmap.height(), px)) +} + +/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up. +fn render(tree: &usvg::Tree, k: f32) -> Result { let side = |v: f32| ((v * k).ceil() as u32).max(1); let (w, h) = (side(tree.size().width()), side(tree.size().height())); let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?; - resvg::render(&tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut()); - let rect = Rect::new(0, 0, w as i32, h as i32); - let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect(); - Ok(photocraft_compose::Buffer { rect, px }) + resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut()); + Ok(pixmap) } /// A transparent pixmap, or `None` past [`MAX_RASTER_PIXELS`]. @@ -672,7 +714,7 @@ impl Conv { } return; } - let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None })); + let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None })); l.opacity = g.opacity().get(); l.blend = blend(g.blend_mode()); l.vector_mask = clip; @@ -699,7 +741,7 @@ impl Conv { } else { t.id().to_string() }; - out.push(Layer::new(name, LayerContent::Group(Group { children, expanded: false, artboard: None }))); + out.push(Layer::new(name, LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None }))); } } } diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index 7127738daa..426962fa9c 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -414,9 +414,31 @@ fn huge_drawings_scale_to_the_side_limit() { assert_eq!(w.len(), 1); } +/// A pasted drawing is one pixel per SVG unit, and a drawing past the pixel limit is scaled +/// down to fit it, keeping its proportions, instead of failing. +#[test] +fn rgba8_renders_at_its_own_size_within_the_pixel_limit() { + let svg = br##""##; + let (w, h, px) = rasterize_rgba8(svg).unwrap(); + assert_eq!((w, h, px.len()), (40, 20, 40 * 20 * 4)); + assert_eq!(px[..4], [255, 0, 0, 255], "the rectangle"); + assert_eq!(px[(10 * 40 + 30) * 4..][..4], [0, 0, 0, 0], "transparent beside it"); + for (side_w, side_h) in [(10_000u32, 5_000u32), (20_000, 20_000), (20_000, 2_000)] { + let svg = format!(""); + let (w, h, px) = rasterize_rgba8(svg.as_bytes()).unwrap(); + let pixels = u64::from(w) * u64::from(h); + assert!(pixels <= MAX_RASTER_PIXELS && pixels > MAX_RASTER_PIXELS / 2, "{side_w} × {side_h} became {w} × {h}"); + assert!(w <= MAX_SIDE && h <= MAX_SIDE, "{w} × {h}"); + let (aspect, want) = (f64::from(w) / f64::from(h), f64::from(side_w) / f64::from(side_h)); + assert!((aspect / want - 1.0).abs() < 0.01, "{w} × {h} keeps {side_w} × {side_h}"); + assert_eq!(px.len() as u64, pixels * 4); + } + assert!(rasterize_rgba8(b""; let once = gz(svg); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 2949a551cc..ba35e53fed 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -118,7 +118,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result