diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..2408084cf9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2c4b8cb1a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4303f0aac4..b06e15449d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,8 @@ concurrency: jobs: # Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and # Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by - # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. + # platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or + # the lockfile. Format changes must exercise the Windows-only publication barriers (#8034). # macOS is tested only by release.yml. platforms: timeout-minutes: 10 @@ -78,7 +79,7 @@ jobs: else echo "web=false" >> "$GITHUB_OUTPUT" fi - if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then + if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then echo "legs=$all" >> "$GITHUB_OUTPUT" else echo "legs=[$linux]" >> "$GITHUB_OUTPUT" @@ -113,7 +114,7 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt @@ -122,7 +123,7 @@ jobs: # A failing test still saves: the cache holds compiled dependencies, not test results. # The Windows legs share one key (same dependencies); the integration leg saves it, # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }} cache-on-failure: true @@ -193,6 +194,16 @@ jobs: cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash + # Every command against adversarial params must return Err, never panic or hang (AGENTS.md + # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as + # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency + # features and recompile the whole graph. The `ci` profile terminates it if it hangs. + - name: Panic hunt + if: runner.os == 'Linux' + run: >- + cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests + --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' + - name: Doctests if: matrix.part == 'all' || matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc @@ -241,9 +252,9 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: shared-key: test save-if: false @@ -296,8 +307,8 @@ jobs: CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: corpus save-if: ${{ github.ref == 'refs/heads/main' }} @@ -318,10 +329,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 save-if: ${{ github.ref == 'refs/heads/main' }} @@ -341,17 +352,17 @@ jobs: TRUNK_VERSION: 0.21.14 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: ci-web save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - name: Install trunk run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ + curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ | tar -xz -C "$HOME/.cargo/bin" trunk trunk --version # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into @@ -409,15 +420,17 @@ jobs: - { crate: format, name: load } steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: crates/${{ matrix.target.crate }}/fuzz - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked - name: Fuzz working-directory: crates/${{ matrix.target.crate }} - run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }} + env: + FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} + run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS" - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 67e6b31894..dca0dacfbb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - name: Install mdBook run: cargo install mdbook --version 0.4.52 --locked - name: Build documentation diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 970c6577a4..56d1ad0831 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,5 +32,7 @@ jobs: with: craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 cargo-build-extra-args: "--features heif" - custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1" + # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at + # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests. + custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1" run-package-check: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a8..01cb9cd663 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # 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/nix.yml@main diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 10d22292d2..4b09278804 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -44,9 +44,9 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: perf-release diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..ecee45a6d8 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 97b5ccdbc0..172bc7bd70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written | `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing | | `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing | | `crates//README.md` (where present) | Public API of that crate | -| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` | +| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` | ## 2. Workspace map @@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written crates/ geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles) psd codecs L0 standalone format crates (no workspace deps; publishable) - tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate + tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data) ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format @@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`. - **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`. -- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. +- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. - **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input. - **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic). - **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s. @@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, 8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log. 9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green. +10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). ## 4. Picking work @@ -107,7 +108,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text While iterating, run less and keep the full commands above as the final gate: `cargo test -p --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p --test ` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops. -**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora. +**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora. Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1ae19efa66..d8244b4fec 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,24 +25,29 @@ next to them. | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | 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`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`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`, `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/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 | | `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | -| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -109,11 +114,13 @@ next to the files: | Path (fetched) | Title | Author | Source | License | |---|---|---|---|---| | `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 | +| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 | | `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi | | `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) | | `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) | | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | +| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | | `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | diff --git a/Cargo.lock b/Cargo.lock index 0167dea3b1..98e1290c29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1222,6 +1222,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" @@ -1229,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610 dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", - "roxmltree", + "roxmltree 0.20.0", "tempfile", "winprint", "winsafe", @@ -1891,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1929,6 +1934,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exacl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +dependencies = [ + "bitflags 2.13.2", + "log", + "scopeguard", + "uuid", +] + [[package]] name = "exr" version = "1.74.2" @@ -2099,7 +2116,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2124,9 +2141,17 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", "parlance", "read-fonts", + "roxmltree 0.21.1", "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", ] [[package]] @@ -3028,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3690,7 +3715,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4436,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4613,10 +4638,11 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.9.0" +version = "0.12.0" dependencies = [ "arboard", "async-io", + "chrono", "core-graphics2", "dispatch2", "eframe", @@ -4646,6 +4672,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "tempfile", "winit", "winresource", "winsafe", @@ -4657,7 +4684,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.9.0" +version = "0.12.0" dependencies = [ "crc32fast", "flate2", @@ -4667,7 +4694,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4680,7 +4707,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.9.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4690,9 +4717,10 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", - "photocraft-geom", "photocraft-io", + "photocraft-raw", "rmcp", + "rustix 1.1.5", "schemars", "serde", "serde_json", @@ -4702,13 +4730,14 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-automation", "photocraft-codecs", "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raw", "serde_json", "tokio", "winresource", @@ -4716,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.9.0" +version = "0.12.0" dependencies = [ "moxcms", "rayon", @@ -4724,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.9.0" +version = "0.12.0" dependencies = [ "exr", "flate2", @@ -4745,7 +4774,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-cms", "serde", @@ -4753,7 +4782,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.9.0" +version = "0.12.0" dependencies = [ "criterion", "photocraft-color", @@ -4768,7 +4797,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4779,9 +4808,10 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.9.0" +version = "0.12.0" dependencies = [ "blake3", + "chrono", "craft-print", "exr", "flate2", @@ -4795,6 +4825,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ipp", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4804,20 +4835,23 @@ dependencies = [ "photocraft-text", "photocraft-vector", "rayon", - "roxmltree", + "roxmltree 0.20.0", + "same-file", "serde", "serde_json", "tempfile", "thiserror 2.0.21", "wat", + "winsafe", ] [[package]] name = "photocraft-format" -version = "0.9.0" +version = "0.12.0" dependencies = [ "blake3", "crc32fast", + "exacl", "flate2", "libc", "photocraft-codecs", @@ -4831,21 +4865,23 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "winsafe", ] [[package]] name = "photocraft-geom" -version = "0.9.0" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.9.0" +version = "0.12.0" dependencies = [ "half", "log", + "photocraft-cms", "photocraft-color", "photocraft-compose", "photocraft-doc", @@ -4859,16 +4895,17 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.9.0" +version = "0.12.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.9.0" +version = "0.12.0" dependencies = [ "blake3", + "craft-docpath", "crc32fast", "flate2", "photocraft-affinity", @@ -4888,21 +4925,25 @@ dependencies = [ "photocraft-vector", "proptest", "resvg", - "roxmltree", + "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ipp" +version = "0.12.0" + [[package]] name = "photocraft-ops" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4913,7 +4954,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4928,7 +4969,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.9.0" +version = "0.12.0" dependencies = [ "flate2", "photocraft-psd", @@ -4939,17 +4980,18 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", "proptest", + "rayon", "serde", ] [[package]] name = "photocraft-raw" -version = "0.9.0" +version = "0.12.0" dependencies = [ "flate2", "image", @@ -4964,7 +5006,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.9.0" +version = "0.12.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4976,18 +5018,15 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.9.0" +version = "0.12.0" dependencies = [ "memory-stats", - "photocraft-color", - "photocraft-geom", - "photocraft-raster", "serde_json", ] [[package]] name = "photocraft-text" -version = "0.9.0" +version = "0.12.0" dependencies = [ "flate2", "parley", @@ -4996,12 +5035,13 @@ dependencies = [ "photocraft-geom", "photocraft-psd", "photocraft-raster", + "rayon", "skrifa", ] [[package]] name = "photocraft-ui-egui" -version = "0.9.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5036,6 +5076,7 @@ dependencies = [ "serde", "serde_json", "sys-locale", + "tempfile", "toml", "unicode-bidi", "winsafe", @@ -5043,7 +5084,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.9.0" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5054,7 +5095,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.9.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -5076,7 +5117,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.9.0" +version = "0.12.0" dependencies = [ "flate2", "js-sys", @@ -5469,7 +5510,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5930,6 +5971,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5988,7 +6038,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6055,7 +6105,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6693,7 +6743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6920,10 +6970,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7323,7 +7373,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7430,7 +7480,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "rustybuzz", "simplecss", "siphasher", @@ -8083,7 +8133,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8663,8 +8713,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "chrono", "serde", "serde_json", "toml", @@ -8676,6 +8727,17 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 4716812f03..b4a66136b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } +photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -57,6 +59,8 @@ egui = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" criterion = { version = "0.5", default-features = false } getrandom = "0.3" diff --git a/Dockerfile b/Dockerfile index 66734f255a..1803d3a2e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ RUN set -eu; \ asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \ url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \ cd /tmp; \ - curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \ - curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \ + curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \ + curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \ printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \ tar -xzf "$asset" -C /usr/local/bin trunk; \ rm "$asset" "$asset.sha256"; \ diff --git a/README.md b/README.md index 973fb5330d..74a5c78f36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ PhotoCraft icon: an engraved nine-tailed fox on a blue tile +

+

PhotoCraft

@@ -20,6 +24,9 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -199,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable. + PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.

🪄 The everyday essentials

@@ -214,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files. -- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`). +- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`). - **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity. - **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively. @@ -275,6 +282,8 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a flatpak run ai.storyteller.photocraft ``` +The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md). + The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). @@ -334,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi **New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it. -**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..829ff75af8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). +Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). ## Upcoming @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | @@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | +| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index f4848f379a..70f15d5be1 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +[dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } + [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/photocraft-cli/build.rs b/apps/photocraft-cli/build.rs index c757a4a7c5..3951c46b81 100644 --- a/apps/photocraft-cli/build.rs +++ b/apps/photocraft-cli/build.rs @@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return Ok(()); } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); // FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left // to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 03001ab02d..c2497e1bf1 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,19 +15,27 @@ pub const USAGE: &str = "\ photocraft-cli: headless PhotoCraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). + An that is the file is refused, as the result would replace the original; + --in-place allows it. + TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); + --tiff-compression compresses it (none, the default; lzw; zip). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. + --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 + (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. + --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized + (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so + only Gray and CMYK shrink) or progressive. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -69,18 +77,47 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { + name: "convert", + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], + run: convert, + }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], - bare: &["--tiff-layers"], + values: &[ + "--new", + "--cmd", + "--params", + "--out", + "--format", + "--quality", + "--tga-bits", + "--dpx-bits", + "--dds-compression", + "--tiff-compression", + "--jpeg-format", + ], + bare: &["--tiff-layers", "--dds-mipmaps"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], - bare: &["--in-place", "--tiff-layers"], + values: &[ + "--actions", + "--action", + "--in", + "--out", + "--format", + "--quality", + "--tga-bits", + "--dpx-bits", + "--dds-compression", + "--tiff-compression", + "--jpeg-format", + ], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], run: batch, }, Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, @@ -219,6 +256,23 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(c) = a.get("--tiff-compression") { + o.encode.tiff_compression = match c { + "none" => photocraft_codecs::TiffCompression::None, + "lzw" => photocraft_codecs::TiffCompression::Lzw, + "zip" => photocraft_codecs::TiffCompression::Deflate, + _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + }; + } + if let Some(f) = a.get("--jpeg-format") { + o.encode.jpeg_format = + photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; + } + if let Some(c) = a.get("--dds-compression") { + o.encode.dds.compression = + photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; + } + o.encode.dds.mipmaps = a.has("--dds-mipmaps"); Ok(o) } @@ -239,12 +293,28 @@ fn automation_workspace(args: &Args) -> Result { .map_err(|error| error.to_string()) } +/// An output that is the input file itself, however it is spelt (relative, with `./`, through a +/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it +/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` +/// folder (#7437). The paths are compared when the command starts and the save opens `output` +/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards +/// against mistakes, it is not a security boundary. +fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { + if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { + return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); + } + Ok(()) +} + fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { let [input, output] = a.positional.as_slice() else { return Err("convert needs ".into()); }; let opts = export_opts(a)?; - let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + refuse_overwriting_input(a, input, output)?; + let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. + photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; @@ -444,11 +514,32 @@ fn is_input(p: &Path) -> bool { p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) } +/// Drops the Save and view steps the desktop records from `action` (Batch saves each result +/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). +fn strip_shell_steps(action: &mut Action) { + use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; + let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); + for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { + if shell_save_command(&id) || shell_view_command(&id) { + continue; + } + if action.disabled.contains(&i) { + disabled.insert(steps.len()); + } + if action.dialogs.contains(&i) { + dialogs.insert(steps.len()); + } + steps.push((id, p)); + } + (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); +} + fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + set.iter_mut().for_each(strip_shell_steps); for w in &warnings { let _ = writeln!(err, "warning: {w}"); } @@ -751,4 +842,37 @@ mod batch_action_set_tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// Save and view steps recorded inside a batched action set are skipped, also in a called + /// action, and unchecked steps stay unchecked once they are gone (#6860). + #[test] + fn batch_skips_save_and_view_steps_inside_an_action_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, + {"name": "Rotate", "steps": [ + ["file.save", {}], + ["image.imageRotation.90cw", {}], + ["view.fitOnScreen", {}], + ["image.imageRotation.90cw", {}], + ["file.saveAs", {}], + ], "disabled": [3], "dialogs": [1]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + for action in ["Outer", "Rotate"] { + let (code, stdout, stderr) = + invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + assert_eq!(code, 0, "{stdout}{stderr}"); + let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); + } + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/it/cli.rs similarity index 75% rename from apps/photocraft-cli/tests/cli.rs rename to apps/photocraft-cli/tests/it/cli.rs index 3c28a8196e..c31239d14f 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() { gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0"); } gif.push(0x3B); + // A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it + // writes, after the decode warning. let cases = [ - ("whole.jpg", &jpeg[..], None), - ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")), - ("anim.gif", &gif[..], Some("only the first of 3 frames was imported")), + ("whole.jpg", &jpeg[..], None, None), + ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None), + ("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")), ]; - for (name, bytes, warning) in cases { + for (name, bytes, warning, export) in cases { let file = d.join(name); std::fs::write(&file, bytes).unwrap(); let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact")); let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}"); let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))); - assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}"); + let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect(); + assert_eq!( + err.trim(), + expected.join( + " +" + ), + "{name}" + ); } std::fs::remove_dir_all(d).unwrap(); } @@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { } } +/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes +/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that +/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327). +#[test] +fn dds_converts_both_ways_with_the_compression_asked_for() { + let d = tmp("dds"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (args, fourcc, mips) in [ + (&[][..], [0; 4], 1), + (&["--dds-compression", "bc1"][..], *b"DXT1", 1), + (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4), + (&["--dds-compression", "bc3"][..], *b"DXT5", 1), + (&["--dds-mipmaps"][..], [0; 4], 4), + ] { + let out = d.join("a.dds"); + ok(bin().arg("convert").arg(&a).arg(&out).args(args)); + let bytes = std::fs::read(&out).unwrap(); + let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap()); + assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(back.exists(), "{args:?}"); + } + for bad in ["bc7", "dxt2", ""] { + let out = d.join("bad.dds"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}"); + assert!(!out.exists(), "--dds-compression {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] @@ -520,6 +563,33 @@ fn tga_bits_choose_the_targa_depth() { } } +/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it +/// doesn't name is refused and nothing is written. +#[test] +fn jpeg_format_chooses_the_scans() { + let d = tmp("jpeg-format"); + let a = d.join("a.png"); + write_png(&a, 16, 8, 3); + let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] { + let out = d.join("a.jpg"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(f) = format { + convert.args(["--jpeg-format", f]); + } + ok(&mut convert); + assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}"); + } + for bad in ["Progressive", "1", ""] { + let out = d.join("bad.jpg"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}"); + assert!(!out.exists(), "--jpeg-format {bad} wrote a file"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] @@ -566,6 +636,42 @@ fn batch_refuses_to_write_over_its_inputs() { assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced"); } +/// #7437: `convert` to its own input file, however the output is spelt, is refused before +/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492). +#[test] +fn convert_refuses_to_write_over_its_input() { + let d = tmp("convert-in-place"); + write_png(&d.join("in.png"), 8, 4, 5); + let before = std::fs::read(d.join("in.png")).unwrap(); + let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap(); + let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")]; + std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap(); + outs.push(PathBuf::from("hard.png")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap(); + outs.push(PathBuf::from("soft.png")); + } + for out in &outs { + for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] { + let o = convert(out, extra); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display()); + assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}"); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display()); + } + } + // Another file converts as before, leaving the original alone. + ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"])); + assert!(d.join("out.jpg").is_file()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); + // `--in-place` opts in: the result replaces the original, which still opens. + ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"])); + let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let info: Value = serde_json::from_str(&out).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); +} + #[test] fn commands_listing() { let (out, _) = ok(bin().arg("commands")); @@ -753,3 +859,136 @@ fn batch_plays_a_photoshop_actions_file() { assert!(err.contains("Generative Fill"), "{err}"); assert!(!o.status.success()); } + +/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`. +fn invoke(args: &[&str]) -> (i32, String, String) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let code = photocraft_cli::run(&args, &mut out, &mut err); + (code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap()) +} + +const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; + +/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr. +#[test] +fn top_level_help_version_and_no_arguments() { + let (code, out, err) = invoke(&[]); + assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false)); + for help in ["--help", "-h", "help"] { + let (code, out, err) = invoke(&[help]); + assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}"); + } + for version in ["--version", "version"] { + let (code, out, err) = invoke(&[version]); + assert_eq!((code, err.as_str()), (0, ""), "{version}"); + assert!(out.starts_with("photocraft-cli "), "{version}: {out}"); + } + let (code, out, err) = invoke(&["frobnicate"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("frobnicate"), "{err}"); +} + +/// ` --help` / `-h` prints help on stdout and returns without running the subcommand +/// (`mcp` and `serve` would otherwise block on stdin). +#[test] +fn every_subcommand_help_returns_on_stdout() { + for sub in SUBCOMMANDS { + for help in ["--help", "-h"] { + let (code, out, err) = invoke(&[sub, help]); + assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}"); + // `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't. + assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}"); + } + } +} + +/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr. +#[test] +fn every_subcommand_rejects_an_unknown_flag() { + for sub in SUBCOMMANDS { + for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] { + let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect(); + let (code, out, err) = invoke(&args); + assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}"); + assert!(err.contains("--fromat"), "{args:?}: {err}"); + } + } +} + +/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags. +#[test] +fn flag_value_forms_and_repeats() { + let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::>(); + let (code, eq, err) = invoke(&["commands", "--filter=file.new"]); + assert_eq!(code, 0, "{err}"); + let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]); + assert_eq!(eq, spaced); + assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}"); + // The last of a repeated value flag wins, whichever order they come in. + let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]); + assert_eq!(code, 0, "{err}"); + assert_eq!(out, eq); + let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]); + assert_eq!(code, 0, "{err}"); + assert!(out.is_empty(), "{out}"); + // A bare flag takes no value; a value flag needs one. + let (code, out, err) = invoke(&["commands", "--json=1"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("--json"), "{err}"); + let (code, out, err) = invoke(&["commands", "--filter"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("--filter"), "{err}"); +} + +/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a +/// `--params` ahead of every `--cmd` fails naming `--params`. +#[test] +fn run_pairs_params_with_the_preceding_cmd() { + let (code, out, err) = invoke(&[ + "run", + "--new", + r#"{"width":8,"height":8}"#, + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"First"}"#, + "--cmd", + "layer.new.layer", + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"Third"}"#, + "--cmd", + "document.inspect", + ]); + assert_eq!(code, 0, "{err}"); + let rows: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(rows.len(), 4, "{out}"); + let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); + let count = |n: &str| names.iter().filter(|x| **x == n).count(); + assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}"); + // Three new layers plus the background; the middle one (no --params) has a default name. + assert_eq!(names.len(), 4, "{names:?}"); + // Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params + // with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead. + assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}"); + let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); + assert_ne!(code, 0); + assert!(err.contains("--params"), "{err}"); +} + +/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as +/// `run`, `info` and the app do; it wrote 72. +#[test] +fn convert_opens_a_raw_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let d = tmp("convert-raw"); + let (w, h) = (24, 16); + let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd")); + std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + ok(bin().args(["convert"]).arg(&raw).arg(&psd)); + let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document; + assert_eq!(doc.resolution_dpi, 300.0); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs new file mode 100644 index 0000000000..581c983b3c --- /dev/null +++ b/apps/photocraft-cli/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod cli; +mod orientation; diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs similarity index 100% rename from apps/photocraft-cli/tests/orientation.rs rename to apps/photocraft-cli/tests/it/orientation.rs diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml index dd12341af2..7443ead089 100644 --- a/apps/photocraft-web-boot/Cargo.toml +++ b/apps/photocraft-web-boot/Cargo.toml @@ -21,4 +21,4 @@ flate2 = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs index 56c2bf1517..c7dc50d9d5 100644 --- a/apps/photocraft-web-boot/src/main.rs +++ b/apps/photocraft-web-boot/src/main.rs @@ -8,7 +8,10 @@ //! one part, however large the app grows. //! //! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80). -//! In one that can't, this module inflates them itself, a little slower. +//! In one that can't, this module inflates them itself, a little slower. Some servers recognise +//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic` +//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then +//! taken as it is, by its first bytes. //! //! A download that fails is tried again, the later tries past the browser's cache (a bad copy //! there would otherwise fail forever). When every try fails the page says so and offers a reload. @@ -36,6 +39,34 @@ fn parts(list: &str) -> Option> { (!parts.is_empty() && parts.iter().all(plain)).then_some(parts) } +/// What the joined parts hold, told by their first bytes. +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Payload { + /// The gzip packaging made, still to be inflated. + Gzip, + /// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser + /// already undid. + Wasm, +} + +/// Enough leading bytes to tell the payloads apart. +#[cfg(any(target_arch = "wasm32", test))] +const HEAD: usize = 4; + +/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or +/// `None` when it is neither, such as a web page or nothing at all. +#[cfg(any(target_arch = "wasm32", test))] +fn payload(head: &[u8]) -> Option { + if head.starts_with(&[0x1f, 0x8b]) { + Some(Payload::Gzip) + } else if head.starts_with(b"\0asm") { + Some(Payload::Wasm) + } else { + None + } +} + /// Gzip inflated a chunk at a time, for browsers that can't do it themselves. #[cfg(any(target_arch = "wasm32", test))] struct Inflater(flate2::write::GzDecoder>); @@ -69,11 +100,11 @@ mod boot { use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ - Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream, - TransformStreamDefaultController, Transformer, WritableStream, + Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, + TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter, }; - use super::Inflater; + use super::{HEAD, Inflater, Payload}; /// Where packaging puts the app, next to this module's own files. const PARTS: &str = "./app/parts.txt"; @@ -172,11 +203,71 @@ mod boot { Ok(compressed.pipe_through(&pair)) } + /// The next chunk `reader` gives, or `None` at the end of the stream. + async fn read(reader: &ReadableStreamDefaultReader) -> Result, JsValue> { + let result = JsFuture::from(reader.read()).await?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() { + return Ok(None); + } + Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?)) + } + + /// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up + /// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending. + async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await { + let _ = reader.cancel_with_reason(&e); + return Err(e); + } + Ok(()) + } + + /// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it. + async fn pass_on(held: Vec, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + for chunk in &held { + write(chunk, reader, writer).await?; + } + while let Some(chunk) = read(reader).await? { + write(&chunk, reader, writer).await?; + } + JsFuture::from(writer.close()).await?; + Ok(()) + } + + /// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the + /// browser has already done that. + async fn unpacked(joined: &ReadableStream) -> Result { + let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into(); + let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new()); + while head.len() < HEAD { + let Some(chunk) = read(&reader).await? else { break }; + let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length()); + head.extend(chunk.subarray(0, wanted).to_vec()); + held.push(chunk); + } + let Some(payload) = super::payload(&head) else { + let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly"); + // Stops the rest of the download, which nothing will read. + let _ = reader.cancel_with_reason(&e); + return Err(e); + }; + let rest = TransformStream::new()?; + let writer = rest.writable().get_writer()?; + spawn_local(async move { + if let Err(e) = pass_on(held, &reader, &writer).await { + let _ = writer.abort_with_reason(&e); + } + }); + match payload { + Payload::Gzip => inflated(&rest.readable()), + Payload::Wasm => Ok(rest.readable()), + } + } + async fn run(fresh: bool) -> Result<(), JsValue> { let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default(); let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?; let joined = TransformStream::new()?; - let wasm = inflated(&joined.readable())?; // Why the download stopped: the app's loader only reports that its stream broke. let cause = Rc::new(RefCell::new(None)); let failed = cause.clone(); @@ -187,6 +278,10 @@ mod boot { failed.replace(Some(e)); } }); + let wasm = match unpacked(&joined.readable()).await { + Ok(wasm) => wasm, + Err(e) => return Err(cause.take().unwrap_or(e)), + }; let headers = Headers::new()?; headers.set("Content-Type", "application/wasm")?; let init = ResponseInit::new(); @@ -258,7 +353,7 @@ fn main() { #[cfg(test)] mod tests { - use super::{Inflater, MARKER, parts}; + use super::{HEAD, Inflater, MARKER, Payload, parts, payload}; fn gzip(bytes: &[u8]) -> Vec { let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); @@ -317,6 +412,22 @@ mod tests { assert!(run(b"").is_err(), "nothing at all"); } + /// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with + /// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts + /// are then the app's WebAssembly, which must start as it is, not be inflated a second time. + #[test] + fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() { + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + wasm.extend(sample()); + let packed = gzip(&wasm); + assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged"); + assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser"); + assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD"); + assert_eq!(payload(b""), None, "a web page"); + assert_eq!(payload(b"\0as"), None, "a cut-off wasm header"); + assert_eq!(payload(b""), None, "nothing at all"); + } + #[test] fn a_parts_list_gives_its_names_in_order() { let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n"); diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 786165b532..4ecad80ae4 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..fc17612908 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -1,6 +1,8 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -15,11 +17,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. +/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "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", "dds", "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", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", + "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -79,11 +82,13 @@ pub fn start() { Box::new(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); + let place_inbox: Inbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); preset_warnings.extend(warnings); - let mut app = PhotocraftApp::new(session, services(inbox.clone())); + let drag = listen_drag(&pen_target); + let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag))); if !preset_warnings.is_empty() { photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None); } @@ -97,7 +102,7 @@ pub fn start() { } let unsaved = Arc::new(AtomicBool::new(false)); guard_unload(unsaved.clone()); - Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets })) + Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets })) }), ) .await; @@ -140,6 +145,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty } } +/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`, +/// `clientY`): the last `dragover` or `drop` event's. +type DragPos = Rc>>; + +/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the +/// page gets no pointer events during a drag, so this is where a drop lands: on the document's +/// canvas it places the files, elsewhere it opens them (#5749). +fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos { + use wasm_bindgen::closure::Closure; + let pos = DragPos::default(); + for kind in ["dragover", "drop"] { + let pos = pos.clone(); + // A DragEvent is a MouseEvent: only its client position is read. + let cb = Closure::::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32)))); + if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() { + cb.forget(); + } + } + pos +} + +/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe +/// maps pointer events (relative to the canvas, divided by the zoom factor). +fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn { + Box::new(move |ctx: &egui::Context| { + let (x, y) = pos.get()?; + let rect = canvas.get_bounding_client_rect(); + let zoom = ctx.zoom_factor(); + (zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom)) + }) +} + /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is /// refreshed every frame by [`WebShell`]. @@ -219,10 +256,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, Stri } /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously, -/// so the app's own drop path can't handle them) and feed them through the inbox. +/// so the app's own drop path can't handle them) and feed them through the inboxes. struct WebShell { app: PhotocraftApp, + /// Dropped files to open as documents. inbox: Inbox, + /// Dropped files to place on the active document's canvas. + place_inbox: Inbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -283,19 +323,34 @@ impl WebShell { } impl eframe::App for WebShell { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the + // twin press closes the menu the first one opened (#2095). + photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input); + } + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); + if !dropped.is_empty() { + let names: Vec = + dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect(); + // Decided now, where the drop landed: on the document's canvas the files are placed + // as layers (as on the desktop), elsewhere opened. + let places = self.app.dropped_on_canvas(ctx, &names); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); + // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); + for ((f, name), place) in files { + match f.bytes_async().await { + Ok(bytes) => { + let queue = if place { &place_inbox } else { &inbox }; + queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -333,11 +388,11 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox) -> Services { +fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) @@ -379,11 +434,15 @@ fn services(inbox: Inbox) -> Services { } })), write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), + // A download may be cancelled and never reports back: it doesn't save the document. + write_unconfirmed: true, encode_png: Some(Box::new(|w, h, rgba| { let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) })), inbox: Some(inbox), + place_inbox: Some(place_inbox), + cursor_pos: Some(cursor_pos), // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 8b7c832bb2..4ba562db68 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -33,6 +33,7 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } @@ -50,11 +51,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" # Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the +# system alert sound for Beep When Done (services.rs, NSBeep). Already in # Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } @@ -65,6 +67,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } +# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done. +tempfile = "3" x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for # image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly. diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index cbba622589..48abc9f1a5 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,6 +11,14 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/photocraft.ico") .set("ProductName", "PhotoCraft") diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 5775e254df..a75520f3c4 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -10,8 +10,8 @@ //! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows //! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`. //! -//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the -//! roaming profile ([`recovery_dir`]). +//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced +//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option { }) } -/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every -/// few minutes, so on Windows the platform convention puts them in the machine-local -/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and -/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before -/// a crashed document is recovered. The override and portable folders keep everything in one -/// place, and other platforms already keep these folders local. +/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few +/// minutes, so with the platform convention they stay out of what is synced with the settings: +/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming +/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy. +/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default +/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that +/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy +/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored. +/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put +/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the +/// system. +/// +/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed +/// document is recovered. The override and portable folders keep everything in one place. pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { - if cfg!(windows) - && data.mode == Mode::Platform - && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) - { - return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + if data.mode == Mode::Platform { + if cfg!(windows) { + if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + } else if !cfg!(target_os = "macos") { + let state = env("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|s| s.is_absolute()) + .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state"))); + if let Some(state) = state { + return Some(state.join("photocraft").join(RECOVERY)); + } + } } data.dir.as_ref().map(|d| d.join(RECOVERY)) } @@ -623,6 +640,34 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default + /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows + /// and macOS don't read it, and an override or portable folder keeps them inside itself. + #[test] + fn autosaves_go_to_the_xdg_state_folder() { + let root = temp("recovery-xdg"); + let settings = root.join("home").join(".config").join("photocraft"); + let home = root.join("home"); + let state = root.join("state"); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let xdg = !cfg!(windows) && !cfg!(target_os = "macos"); + let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") }); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery"))); + let default = home.join(".local").join("state").join("photocraft").join("Recovery"); + assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone())); + let relative = PathBuf::from("relative/state"); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone())); + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default)); + // No home at all: beside the settings, as before. + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + /// An autosave bundle and its sidecar, as `format::Autosaver` writes them. fn autosave(dir: &Path, key: &str) { std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap(); @@ -784,6 +829,7 @@ mod tests { /// now-existing folder and the original is put back, so both autosaves stay (#6412 review). #[test] fn a_launch_that_times_out_never_writes_where_a_move_removes() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-timeout"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "old"); @@ -866,6 +912,7 @@ mod tests { /// once the lock is released, the move happens. #[test] fn a_move_waits_for_another_process_holding_the_lock() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-lock"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "a"); diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index 54eea2d6b5..e20e70206a 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle)) } +/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`] +/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon +/// on the Dock after the window opens (#7253). +pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData { + if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() } +} + /// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. fn in_app_bundle(exe: &std::path::Path) -> bool { let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); @@ -80,6 +87,19 @@ mod tests { assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos")); } + /// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded. + #[test] + fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() { + use std::path::Path; + let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")); + if sets_runtime_icon(bundled) { + let icon = viewport_icon(bundled); + assert!(icon.width >= 256 && icon.width == icon.height); + } else { + assert_eq!(viewport_icon(bundled), egui::IconData::default()); + } + } + #[test] fn window_icon_decodes() { let icon = window_icon(); diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 30ae610b70..d73984eb8a 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver Receiver String { + match listener.local_addr() { + Ok(addr) => addr.to_string(), + Err(_) => format!("127.0.0.1:{requested_port}"), + } +} + fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); } @@ -131,6 +141,17 @@ mod tests { use super::*; use std::io::BufRead; + /// #6427: with port 0 the log names the port the OS bound, which a client can connect to. + #[test] + fn port_zero_logs_the_bound_port() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let bound = listener.local_addr().unwrap(); + assert_ne!(bound.port(), 0); + let logged = listening_addr(&listener, 0); + assert_eq!(logged, format!("127.0.0.1:{}", bound.port())); + TcpStream::connect(&logged).unwrap(); + } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline /// (releasing its connection permit), and nothing it sent is dispatched. #[test] diff --git a/apps/photocraft/src/file_dialog_probe.rs b/apps/photocraft/src/file_dialog_probe.rs index 9ee14c0afb..eed74a58f2 100644 --- a/apps/photocraft/src/file_dialog_probe.rs +++ b/apps/photocraft/src/file_dialog_probe.rs @@ -70,25 +70,61 @@ pub fn start() { /// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the /// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply). +/// +/// Asks the portal object to introspect itself rather than reading the interface's `version` +/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without +/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check +/// didn't count as absent (#5739). The interface list answers the question directly. #[cfg(target_os = "linux")] fn file_chooser_portal() -> Option { let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else { return Some(false); }; - let proxy = zbus::blocking::proxy::Builder::>::new(&conn) - .destination("org.freedesktop.portal.Desktop") - .and_then(|b| b.path("/org/freedesktop/portal/desktop")) - .and_then(|b| b.interface("org.freedesktop.portal.FileChooser")) - .and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build()) - .ok()?; - match proxy.get_property::("version") { - Ok(_) => Some(true), - Err(zbus::Error::MethodError(name, _, _)) => { - let name = name.as_str(); - let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n)); - absent.then_some(false) - } - Err(_) => None, + let reply = conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.DBus.Introspectable"), + "Introspect", + &(), + ); + match reply { + Ok(message) => match message.body().deserialize::() { + Ok(xml) => Some(interface_listed(&xml)), + Err(_) => None, + }, + Err(e) => classify_error(&e), + } +} + +/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as +/// `FileChooser2`). +#[cfg(target_os = "linux")] +fn interface_listed(xml: &str) -> bool { + ["\"", "'"].iter().any(|quote| xml.contains(&format!(" Option { + use zbus::fdo::Error as Fdo; + // Names a bus daemon or portal answers with when the thing asked about is absent. + const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"]; + match e { + zbus::Error::FDO(fdo) => matches!( + **fdo, + Fdo::ServiceUnknown(_) + | Fdo::NameHasNoOwner(_) + | Fdo::UnknownObject(_) + | Fdo::UnknownInterface(_) + | Fdo::UnknownMethod(_) + | Fdo::UnknownProperty(_) + | Fdo::InvalidArgs(_) + ) + .then_some(false), + zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false), + _ => None, } } @@ -126,4 +162,69 @@ mod tests { assert!(!on_path("kdialog", &path)); std::fs::remove_dir_all(&dir).unwrap(); } + + #[cfg(target_os = "linux")] + mod portal { + use super::super::*; + + const WITH_FILE_CHOOSER: &str = r#" + + + + + + +"#; + const WITHOUT_FILE_CHOOSER: &str = r#" + + +"#; + + fn method_error(name: &'static str) -> zbus::Error { + let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap(); + let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap(); + zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply) + } + + #[test] + fn the_interface_list_says_whether_a_file_chooser_is_offered() { + assert!(interface_listed(WITH_FILE_CHOOSER)); + assert!(interface_listed(""), "single quotes"); + assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend"); + assert!(!interface_listed(r#""#), "longer name"); + assert!(!interface_listed("")); + } + + #[test] + fn dbus_errors_for_a_missing_interface_mean_absent() { + // zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as + // `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface") + // when no backend offers the interface (#5739). + for e in [ + zbus::fdo::Error::InvalidArgs("No such interface".into()), + zbus::fdo::Error::UnknownInterface("x".into()), + zbus::fdo::Error::ServiceUnknown("x".into()), + zbus::fdo::Error::NameHasNoOwner("x".into()), + zbus::fdo::Error::UnknownObject("x".into()), + ] { + let desc = format!("{e:?}"); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}"); + } + } + + #[test] + fn method_error_names_still_count_as_absent() { + for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] { + assert_eq!(classify_error(&method_error(name)), Some(false), "{name}"); + } + assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None); + } + + #[test] + fn inconclusive_errors_are_not_absent() { + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None); + assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None); + } + } } diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index e79b4bafac..9b08d4d292 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -12,8 +12,10 @@ //! - macOS: Metal → CPU //! //! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance, -//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still -//! overrides everything, and `--safe-gpu` forces the CPU path for one launch. +//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows +//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first +//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything, +//! and `--safe-gpu` forces the CPU path for one launch. //! //! "CPU" composites on the CPU and draws the window with a software adapter where the platform //! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present @@ -33,6 +35,10 @@ use serde_json::{Value, json}; pub const MARKER_FILE: &str = "gpu-starting.json"; /// PCI vendor id of Intel. pub const INTEL: u32 = 0x8086; +/// Qualcomm's vendor id as its Vulkan driver reports it. +pub const QUALCOMM: u32 = 0x5143; +/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM". +pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351; /// Largest marker read (a marker is a few hundred bytes). const MARKER_MAX: u64 = 64 << 10; @@ -204,6 +210,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option { }) } +/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor +/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An +/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's +/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]' +/// choice. +pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec) -> Option { + let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows; + (auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL) +} + +/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver) +/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]). +fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::DX12, + flags: desc.flags, + backend_options: desc.backend_options.clone(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect() +} + /// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the /// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC /// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader @@ -220,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com } } +/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's +/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on +/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same +/// machine didn't, #7537). The name is for System Info. +fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> { + match vendor { + INTEL => Some("Intel"), + QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"), + _ => None, + } +} + /// What adapter selection needs to know about an adapter. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Candidate { @@ -228,13 +268,16 @@ pub struct Candidate { pub backend: wgpu::Backend, /// Can present to the window. pub surface_ok: bool, + /// Its name matches the adapter the user asked for ([`adapter_matches`]). + pub wanted: bool, } /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for -/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter, -/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788). +/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes +/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On +/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only +/// old GPUs) drew a black window (#5191, #1788). fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl)); let ty = match a.device_type { @@ -250,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { _ => ty, }; let be = match a.backend { - wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0, + wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0, wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1, wgpu::Backend::Dx12 => 2, wgpu::Backend::Gl => 3, @@ -259,19 +302,62 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { (fallback, ty, be) } -/// The adapter to use among `adapters` (`None` when there is none). +/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for +/// win over the rest when any of them can present; otherwise the ranking decides (see +/// [`wanted_missed`]). pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option { - adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i) + let usable = |a: &&Candidate| a.surface_ok; + let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted); + adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i) } -/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the -/// same vendor was available). -pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool { - let Some(c) = adapters.get(chosen) else { return false }; - os == Os::Windows - && c.vendor == INTEL - && c.backend == wgpu::Backend::Dx12 - && adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan) +/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive +/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds +/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing. +pub fn adapter_matches(name: &str, wanted: &str) -> bool { + photocraft_engine::prefs::gpu_adapter_matches(name, wanted) +} + +/// The user asked for an adapter and none of `adapters` that can present is it. +pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool { + wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted) +} + +/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a +/// single Metal path, and neither is tested with a requested adapter. +pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux"); + +/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode, +/// which wants a software adapter whatever the preference says. +pub fn applicable_adapter(wanted: Option, backend: GpuBackend, linux: bool) -> Option { + wanted.filter(|_| linux && backend != GpuBackend::Cpu) +} + +/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act +/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the +/// launch may still be forwarded to the running window. +pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool { + env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu +} + +/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else +/// `performance.gpuAdapter`; `None` is automatic. +pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option { + [env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect()) +} + +/// The maker's name when picking `chosen` applied the Windows DX12 default of +/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel, +/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance). +pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> { + let c = adapters.get(chosen)?; + if os != Os::Windows || c.backend != wgpu::Backend::Dx12 { + return None; + } + let maker = dx12_first_on_windows(c.vendor)?; + let applied = + (vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker)); + applied.then_some(maker) } /// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler @@ -283,9 +369,9 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool { } /// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter -/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the -/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default). -pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc>>) { +/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update +/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default). +pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option, sentinel: SharedSentinel, note: Arc>>) { if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { if let Some(b) = backends(plan, os) { create.instance_descriptor.backends = b; @@ -298,7 +384,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel let env = std::env::var("WGPU_DX12_COMPILER").ok(); create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent)); } - let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)); + let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor)); + if let Some(b) = without_vulkan { + create.instance_descriptor.backends = b; + } + let vulkan_left_out = without_vulkan.is_some(); + let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE); + let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows))); if select { let backend = plan.backend; create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| { @@ -311,17 +403,24 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel device_type: i.device_type, backend: i.backend, surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)), + wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)), } }) .collect(); let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?; + if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) { + *note.lock().unwrap_or_else(PoisonError::into_inner) = + Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice")); + } if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) { *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into()); } - if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) { + if backend == GpuBackend::Auto + && let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out) + { *note.lock().unwrap_or_else(PoisonError::into_inner) = - Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into()); + Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)")); } adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string()) })); @@ -443,6 +542,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window +/// opens; empty (automatic) when the file or value is missing. +pub fn read_gpu_adapter(path: Option<&Path>) -> String { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect() +} + /// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless /// turned on), read before the window opens: it picks the surface's present mode. pub fn read_low_latency(path: Option<&Path>) -> bool { @@ -604,7 +710,82 @@ mod tests { } fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate { - Candidate { vendor, device_type, backend, surface_ok: true } + Candidate { vendor, device_type, backend, surface_ok: true, wanted: false } + } + + #[test] + fn a_requested_adapter_beats_the_automatic_ranking() { + use wgpu::{Backend as B, DeviceType as T}; + // The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU. + let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc"); + a[2].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(2)); + a[2].wanted = false; + a[0].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for"); + // A wanted adapter that can't present is skipped, not an error. + a[0].surface_ok = false; + assert_eq!(pick(&a, Auto, Os::Other), Some(1)); + assert!(wanted_missed(&a, Some("iris"))); + assert!(!wanted_missed(&a, None)); + a[0].surface_ok = true; + assert!(!wanted_missed(&a, Some("iris"))); + // Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides. + let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + gl.iter_mut().for_each(|c| c.wanted = true); + assert_eq!(pick(&gl, Auto, Os::Other), Some(1)); + } + + #[test] + fn an_adapter_request_applies_on_linux_outside_cpu_mode() { + let w = || Some("nvidia".to_string()); + assert_eq!(applicable_adapter(w(), Auto, true), w()); + assert_eq!(applicable_adapter(w(), Vulkan, true), w()); + assert_eq!(applicable_adapter(w(), Cpu, true), None); + assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection"); + assert_eq!(applicable_adapter(None, Auto, true), None); + } + + #[test] + fn an_adapter_override_keeps_its_own_process_only_when_it_applies() { + let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux); + assert!(on(Some("nvidia"), RenderingMode::Auto, true)); + assert!(on(Some("nvidia"), RenderingMode::Gpu, true)); + assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode"); + assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux"); + assert!(!on(Some(" "), RenderingMode::Auto, true)); + assert!(!on(None, RenderingMode::Auto, true)); + } + + #[test] + fn adapter_names_match_loosely_and_the_environment_wins() { + assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M ")); + assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc")); + assert!(!adapter_matches("anything", "")); + assert!(!adapter_matches("anything", " ")); + assert_eq!(wanted_adapter(None, ""), None); + assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into())); + assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into())); + assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256)); + } + + #[test] + fn gpu_adapter_is_read_before_the_window_opens() { + let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_gpu_adapter(Some(&file)) + }; + assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia"); + assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), ""); + assert_eq!(read(r#"{"performance":{}}"#), ""); + assert_eq!(read("not json"), ""); + assert_eq!(read_gpu_adapter(None), ""); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -613,13 +794,13 @@ mod tests { let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; let i = pick(&intel, Auto, Os::Windows).unwrap(); assert_eq!(intel[i].backend, B::Dx12); - assert!(intel_dx12_applied(&intel, i, Os::Windows)); + assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel")); // Elsewhere (and for other vendors) Vulkan stays first. assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan); let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; let i = pick(&nv, Auto, Os::Windows).unwrap(); assert_eq!(nv[i].backend, B::Vulkan); - assert!(!intel_dx12_applied(&nv, i, Os::Windows)); + assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None); // Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan. let hybrid = [intel[0], intel[1], nv[1]]; assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2)); @@ -630,6 +811,66 @@ mod tests { assert_eq!(pick(&[], Auto, Os::Windows), None); } + /// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the + /// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids. + #[test] + fn qualcomm_on_windows_defaults_to_dx12() { + use wgpu::{Backend as B, DeviceType as T}; + let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)]; + let i = pick(&adreno, Auto, Os::Windows).unwrap(); + assert_eq!(adreno[i].backend, B::Dx12); + assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm")); + // Only Vulkan offered: still Vulkan, and no note. + assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0)); + assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None); + // An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default". + let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]]; + assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None); + // Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel. + assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None); + // Elsewhere Vulkan stays first. + assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan); + } + + /// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before + /// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on + /// Windows creates the instance without Vulkan. + #[test] + fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() { + use wgpu::{Backend as B, DeviceType as T}; + const NVIDIA: u32 = 0x10de; + const AMD: u32 = 0x1002; + const WARP: u32 = 0x1414; + let auto = plan(Auto, None, None, false, Os::Windows); + let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl); + // Intel iGPU + discrete GPU: the discrete GPU is then used on DX12. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl); + // No Intel adapter: egui's default backends, Vulkan included. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None); + // `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms + // don't list the DX12 adapters at all. + let not_listed = || -> Vec { panic!("DX12 adapters listed") }; + for (p, os) in [ + (plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows), + (plan(Auto, None, None, true, Os::Windows), Os::Windows), + (plan(Vulkan, None, None, false, Os::Windows), Os::Windows), + (plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows), + (plan(Auto, None, None, false, Os::Other), Os::Other), + (plan(Auto, None, None, false, Os::Mac), Os::Mac), + ] { + assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}"); + } + // Without Vulkan adapters to compare with, System Info still names the Intel default. + let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; + let i = pick(&listed, Auto, Os::Windows).unwrap(); + assert_eq!(i, 0); + assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel")); + assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None); + } + #[test] fn cpu_prefers_software_adapters() { use wgpu::{Backend as B, DeviceType as T}; @@ -894,7 +1135,7 @@ mod tests { // The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path). let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let plan = plan(Auto, None, None, false, Os::Windows); - configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; if std::env::var_os("WGPU_DX12_COMPILER").is_none() { let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler { @@ -918,13 +1159,13 @@ mod tests { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") }; let before = before.instance_descriptor.flags; - configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched"); } if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); - configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 292428b8ce..6a0cc8f052 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -4,7 +4,9 @@ //! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no //! terminal, so the file is what a bug report can attach: `/logs/photocraft.log` //! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to -//! `.2`), so the log of a run that crashed survives the next launch. +//! `.2`), so the log of a run that crashed survives the next launch. A start while another +//! instance is still running leaves that instance's log alone and writes `photocraft-.log` +//! (see [`rotate`]). //! //! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are //! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, @@ -15,7 +17,7 @@ //! written once the file is attached. Writing never panics: a file that can't be created or //! written leaves standard error as the only sink. -use std::fs::File; +use std::fs::{File, OpenOptions, TryLockError}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -89,37 +91,112 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) } } -/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) -/// and create a fresh, empty log file in `dir` (created if missing). -pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { +/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock +/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could +/// not be opened for a bug report while the app runs. +pub const OWNER_LOCK: &str = "photocraft.lock"; +/// Held while one start rotates and prunes the logs, so two starts at once can't interleave. +pub const ROTATE_LOCK: &str = "rotate.lock"; + +/// The log of an instance started while another one was writing [`LOG_FILE`]. +fn instance_log(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.log")) +} + +/// The lock file that marks [`instance_log`] as in use. +fn instance_lock(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.lock")) +} + +/// The process id in an [`instance_log`] file name. +fn instance_pid(name: &str) -> Option { + let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn open_lock(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path) +} + +/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it. +fn try_hold(file: &File) -> std::io::Result { + match file.try_lock() { + Ok(()) => Ok(true), + Err(TryLockError::WouldBlock) => Ok(false), + Err(TryLockError::Error(e)) => Err(e), + } +} + +/// Open a fresh, empty log file in `dir` (created if missing). +/// +/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `..log`, the oldest +/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the +/// chain is left alone and this start writes `photocraft-.log` instead: rotating would move +/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side +/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system +/// without locks every start rotates, as before. +pub fn rotate(dir: &Path) -> std::io::Result { std::fs::create_dir_all(dir)?; + // Waits only for another start's few file operations; without locking support, go on. + let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok()); + let log = rotate_locked(dir); + drop(guard); + log +} + +fn rotate_locked(dir: &Path) -> std::io::Result { + prune_instance_logs(dir); + let owner = open_lock(&dir.join(OWNER_LOCK))?; + let state = try_hold(&owner); + if let Ok(false) = state { + let pid = std::process::id(); + let lock = open_lock(&instance_lock(dir, pid))?; + let held = try_hold(&lock).unwrap_or(false); + let path = instance_log(dir, pid); + let file = File::create(&path)?; + return Ok(LogFile { path, file, owner: held.then_some(lock) }); + } + // This start holds the owner lock (or the file system has no locks). + let held = state.unwrap_or(false); for n in (1..=KEEP).rev() { match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), @@ -128,7 +205,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } let path = numbered(dir, 0); let file = File::create(&path)?; - Ok((path, file)) + Ok(LogFile { path, file, owner: held.then_some(owner) }) +} + +/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`]. +/// A log whose lock file is held belongs to a running instance and is never touched. Failures +/// are ignored: pruning only saves disk space. +fn prune_instance_logs(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut finished: Vec<(SystemTime, u32)> = Vec::new(); + for entry in entries.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue }; + // A missing lock file means nobody holds it; an unknown lock state counts as running. + let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) { + Ok(lock) => !try_hold(&lock).unwrap_or(false), + Err(_) => false, + }; + if !running { + let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); + finished.push((modified, pid)); + } + } + finished.sort_unstable_by(|a, b| b.cmp(a)); + for &(_, pid) in finished.iter().skip(KEEP) { + let _ = std::fs::remove_file(instance_log(dir, pid)); + let _ = std::fs::remove_file(instance_lock(dir, pid)); + } +} + +/// A log file opened by [`rotate`]. +pub struct LogFile { + pub path: PathBuf, + pub file: File, + /// The held lock that marks `path` as in use; `None` where locking isn't supported. + owner: Option, } /// One formatted log line: ` [] : \n`. @@ -205,17 +315,20 @@ pub struct AppLogger { filter: Filter, stderr: bool, sink: Mutex, + /// The lock that marks the attached log file as in use (see [`rotate`]); held until exit. + owner: Mutex>, } impl AppLogger { pub fn new(filter: Filter, stderr: bool) -> AppLogger { - AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) } } /// Rotate the logs in `dir` and send records to the fresh file; returns its path. pub fn attach_dir(&self, dir: &Path) -> Result { - let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + *self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner; Ok(path) } } @@ -312,13 +425,28 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("rotate"); for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); + let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate"); assert_eq!(path, dir.join(LOG_FILE)); assert_eq!(std::fs::read_to_string(&path).expect("read"), ""); std::fs::write(&path, format!("run {run}")).expect("write"); @@ -330,6 +458,66 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #6432: a second launch moved the running instance's live log to `.1.log`, a third to + /// `.2.log`, and a fourth deleted it. + #[test] + fn a_second_start_leaves_the_running_instance_log_alone() { + let _spawns = crate::no_child_spawns(); + let dir = temp_dir("second"); + let mut first = rotate(&dir).expect("first start"); + assert_eq!(first.path, dir.join(LOG_FILE)); + first.file.write_all(b"first, before\n").expect("write"); + for _ in 0..KEEP + 1 { + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + assert_eq!(read(&second.path), ""); + } + first.file.write_all(b"first, after\n").expect("write"); + assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n"); + assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated"); + // Once the first instance is gone, the next start rotates as before. + drop(first); + let third = rotate(&dir).expect("third start"); + assert_eq!(third.path, dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n"); + drop(third); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn instance_logs_are_pruned_only_when_no_one_holds_them() { + let _spawns = crate::no_child_spawns(); + let dir = temp_dir("prune"); + let first = rotate(&dir).expect("first start"); + // Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file + // (as if its lock file had been removed), the others an unlocked one. + let now = SystemTime::now(); + for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] { + let path = instance_log(&dir, pid); + std::fs::write(&path, format!("run {pid}")).expect("write"); + File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime"); + if pid != 1 { + std::fs::write(instance_lock(&dir, pid), "").expect("lock file"); + } + } + // One older still running: its log is never removed, however old. + let live = instance_log(&dir, 5); + std::fs::write(&live, "live").expect("write"); + File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime"); + let held = File::create(instance_lock(&dir, 5)).expect("lock file"); + held.try_lock().expect("hold"); + + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + // The newest KEEP finished ones stay, the rest go with their lock files. + assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists()); + assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists()); + assert!(!instance_lock(&dir, 2).exists()); + assert_eq!(read(&live), "live"); + drop((first, second, held)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { let dir = temp_dir("blocked"); @@ -355,7 +543,7 @@ mod tests { #[test] fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); sink.write("early 1\n"); sink.write("early 2\n"); @@ -368,7 +556,7 @@ mod tests { #[test] fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); for i in 0..MAX_PENDING + 5 { sink.write(&format!("line {i}\n")); @@ -384,7 +572,7 @@ mod tests { #[test] fn the_file_stops_at_its_size_cap_with_one_note() { let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(100); sink.attach(file); for i in 0..50 { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index abd70eb418..1b58e89f03 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -3,7 +3,12 @@ //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] //! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] -//! [--frame-latency ] [--stroke-trace ] [--new-instance] [files…]` +//! [--frame-latency ] [--stroke-trace ] [--new-instance] [--] [files…]` +//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version). +//! +//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo +//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file +//! whose name starts with `-` goes after `--`. //! //! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new //! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any @@ -58,13 +63,15 @@ mod photoshop_settings; #[cfg(target_os = "linux")] mod portal_save; mod presentation; +// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere. +#[cfg(any(target_os = "linux", test))] +mod print_portal; mod screen_color; mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). #[cfg(any(not(target_os = "macos"), test))] mod single_instance; -// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. -#[cfg(any(target_os = "macos", target_os = "linux", test))] +// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button). mod tablet; mod ui_state; mod window_frame; @@ -95,9 +102,7 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// different offset every launch (#419). A later launch restores the saved position instead /// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { - let viewport = egui::ViewportBuilder::default(); - // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849). - let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport }; + let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref())); eframe::NativeOptions { viewport: viewport .with_app_id(APP_ID) @@ -149,21 +154,188 @@ fn parse_control_port(value: &str, source: &str) -> Result { value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) } -/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None` -/// when there are none, else 2 (a command-line usage error), never 0. -fn control_args_exit_code(errors: &[String]) -> Option { +/// Process exit status for the collected usage errors (a bad `--control` / +/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a +/// command-line usage error), never 0. +fn usage_exit_code(errors: &[String]) -> Option { if errors.is_empty() { None } else { Some(2) } } +/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. +const USAGE: &str = "\ +Usage: photocraft [options] [--] [files…] + +Opens PhotoCraft with the given files. + +Options: + -h, --help Print this help and exit + --version Print the version, commit and build date and exit + --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) + --in-window-menus macOS: keep the menus inside the window instead of the menu bar + --new-instance Windows and Linux: start a separate PhotoCraft instead of opening + the files in the one already running + --present-mode How frames reach the display: fifo, autovsync or novsync + --frame-latency Frames queued for the display: 1 to 3 + --stroke-trace + Time each brush stroke's frames + --control Start the localhost JSON-lines control server on this port + --control-token <64-hex> Control server token (default: a new random one, printed) + --control-token-file Read the control server token from this file, or create it + with a new one + --automation-read-root Directory the control server may read files from + --automation-write-root + Directory the control server may write files to + -- Treat every later argument as a file, even one starting with `-` + +A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. +Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, +PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, +PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, +PHOTOCRAFT_CONFIG_DIR. +"; + +/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when +/// stdout is closed or a pipe (`photocraft --help | head -1`). +fn print_stdout(text: &str) { + use std::io::Write as _; + let mut out = std::io::stdout().lock(); + let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); +} + +/// Everything the desktop launcher takes from the command line, on top of the environment +/// defaults handed in to `parse_launch_args`. +#[derive(Debug, Default, PartialEq, Eq)] +struct LaunchArgs { + control_port: Option, + control_token: Option, + control_token_file: Option, + automation_read_root: Option, + automation_write_root: Option, + files: Vec, + /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 + /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are + /// reported in a notice once the app is up; a control token is a usage error like a bad port. + unreadable_paths: Vec, + safe_gpu: bool, + new_instance: bool, + /// Options that apply only to the process they're given to; a launch with any keeps its own. + launch_options: Vec<&'static str>, + present_mode_arg: Option, + frame_latency_arg: Option, + stroke_trace_arg: Option, + in_window_menus: bool, + /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. + help: bool, + /// `--version`: print the version and exit 0; parsing stops there. + version: bool, + /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty + /// list fails the launch with exit status 2 (issues #701, #6429, #6431). + errors: Vec, +} + +/// Parse the desktop launch arguments over the environment defaults in `out`. A control or +/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent +/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. +/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. +fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { + let mut args = args; + // After `--`, every argument is a file, even one that starts with `-`. + let mut only_files = false; + while let Some(a) = args.next() { + if only_files { + match a.into_string() { + Ok(s) => out.files.push(s), + Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), + } + continue; + } + match a.to_str() { + Some("--") => only_files = true, + Some("--help" | "-h") => { + out.help = true; + break; + } + Some("--control") => match args.next() { + Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { + Ok(port) => out.control_port = Some(port), + Err(error) => out.errors.push(error), + }, + None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), + }, + Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(token)) => out.control_token = Some(token), + Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), + }, + Some("--control-token-file") => match args.next() { + Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), + }, + Some("--automation-read-root") => { + out.launch_options.push("--automation-read-root"); + match args.next() { + Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), + } + } + Some("--automation-write-root") => { + out.launch_options.push("--automation-write-root"); + match args.next() { + Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), + } + } + Some("--safe-gpu") => { + out.launch_options.push("--safe-gpu"); + out.safe_gpu = true; + } + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + out.launch_options.push(match flag { + "--present-mode" => "--present-mode", + "--frame-latency" => "--frame-latency", + _ => "--stroke-trace", + }); + let flag = flag.to_string(); + match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(v)) => match flag.as_str() { + "--present-mode" => out.present_mode_arg = Some(v), + "--frame-latency" => out.frame_latency_arg = Some(v), + _ => out.stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } + Some("--in-window-menus") => { + out.launch_options.push("--in-window-menus"); + out.in_window_menus = true; + } + Some("--new-instance") => out.new_instance = true, + Some("--version") => { + out.version = true; + break; + } + // Old macOS passes a process serial number when launched from Finder. + Some(s) if s.starts_with("-psn_") => {} + // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window + // and then fail to open the flag as a file (#6431). + Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), + Some(s) => out.files.push(s.to_owned()), + None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), + } + } + out +} + #[cfg(test)] mod control_port_tests { - use super::{control_args_exit_code, parse_control_port}; + use super::{parse_control_port, usage_exit_code}; #[test] fn control_port_errors_exit_non_zero() { - assert_eq!(control_args_exit_code(&[]), None); + assert_eq!(usage_exit_code(&[]), None); let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(control_args_exit_code(&errors), Some(2)); + assert_eq!(usage_exit_code(&errors), Some(2)); } #[test] @@ -184,105 +356,152 @@ mod control_port_tests { } } +#[cfg(test)] +mod launch_args_tests { + use super::{LaunchArgs, parse_launch_args, usage_exit_code}; + use std::ffi::OsString; + + fn parse(values: &[&str]) -> LaunchArgs { + parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // Issue #6429: a missing value must fail the launch, not silently fall back to a + // generated token or to no root. + for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { + let parsed = parse(&[flag]); + assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); + assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); + assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); + assert_eq!(usage_exit_code(&parsed.errors), Some(2)); + } + } + + #[test] + fn missing_value_does_not_clear_an_environment_default() { + // `--automation-read-root` with no value must not drop a root set through the env var. + let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; + let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + } + + #[test] + fn values_are_parsed_and_no_errors_remain() { + let parsed = parse(&[ + "--control", + "50494", + "--control-token", + "abcd", + "--control-token-file", + "/tmp/token", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "mailbox", + "photo.psd", + ]); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + assert_eq!(parsed.control_port, Some(50494)); + assert_eq!(parsed.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu); + assert!(parsed.in_window_menus); + assert!(parsed.new_instance); + assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); + assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); + assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + } + + #[test] + fn version_stops_parsing() { + let parsed = parse(&["--version", "--control"]); + assert!(parsed.version); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + } + + #[test] + fn help_stops_parsing_and_double_dash_ends_options() { + let parsed = parse(&["-h", "--safegpu", "--control"]); + assert!(parsed.help); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + + let parsed = parse(&["--safegpu", "--", "--help", "-x"]); + assert!(!parsed.help); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); + assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); - let mut control_port: Option = None; - let mut control_arg_errors: Vec = Vec::new(); + let mut launch = LaunchArgs { + automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), + automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), + in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), + ..Default::default() + }; if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Ok(port) => launch.control_port = Some(port), + Err(error) => launch.errors.push(error), } } - let mut control_token = None; - let mut control_token_file = None; - let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from); - let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from); - let mut files = Vec::new(); - // Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - // file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - // reported in a notice once the app is up; a control token is a usage error like a bad port. - let mut unreadable_paths: Vec = Vec::new(); - let mut safe_gpu = false; - let mut new_instance = false; - // Options that apply only to the process they're given to; a launch with any keeps its own. - let mut launch_options: Vec<&'static str> = Vec::new(); - let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); - let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut args = std::env::args_os().skip(1); - while let Some(a) = args.next() { - match a.to_str() { - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), - }, - None => control_arg_errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => control_token = Some(token), - Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => control_token = None, - }, - Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), - Some("--automation-read-root") => { - launch_options.push("--automation-read-root"); - automation_read_root = args.next().map(std::path::PathBuf::from); - } - Some("--automation-write-root") => { - launch_options.push("--automation-write-root"); - automation_write_root = args.next().map(std::path::PathBuf::from); - } - Some("--safe-gpu") => { - launch_options.push("--safe-gpu"); - safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => present_mode_arg = Some(v), - "--frame-latency" => frame_latency_arg = Some(v), - _ => stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - launch_options.push("--in-window-menus"); - in_window_menus = true; - } - Some("--new-instance") => new_instance = true, - Some("--version") => { - println!("photocraft {}", photocraft_engine::build_info::long_version()); - return Ok(()); - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - Some(s) => files.push(s.to_owned()), - None => unreadable_paths.push(a.to_string_lossy().into_owned()), - } + launch = parse_launch_args(std::env::args_os().skip(1), launch); + if launch.help { + print_stdout(USAGE); + return Ok(()); + } + if launch.version { + print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); + return Ok(()); } - // A malformed control port must not silently drop the control server (issue #701): name the - // bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo. - // Exit status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = control_args_exit_code(&control_arg_errors) { - for error in &control_arg_errors { + // A malformed control port must not silently drop the control server (issue #701), a control + // or automation flag without its value must not silently fall back to a generated token or + // to no root (issue #6429), and an unknown option must not open the window (#6431): name the + // bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit + // status 2 is the usual command-line usage error, so a launcher sees the failure. + if let Some(code) = usage_exit_code(&launch.errors) { + for error in &launch.errors { eprintln!("photocraft: {error}"); } + eprintln!("Run `photocraft --help` for usage."); std::process::exit(code); } + let LaunchArgs { + control_port, + control_token, + control_token_file, + automation_read_root, + automation_write_root, + files, + unreadable_paths, + safe_gpu, + new_instance, + mut launch_options, + present_mode_arg, + frame_latency_arg, + stroke_trace_arg, + in_window_menus, + help: _, + version: _, + errors: _, + } = 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. @@ -294,12 +513,24 @@ fn main() -> eframe::Result { } } + // 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; + if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) { + launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); + } // 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. #[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, @@ -405,6 +636,11 @@ fn main() -> eframe::Result { let tablet_monitor = std::cell::OnceCell::new(); #[cfg(target_os = "macos")] let tablet_monitor = &tablet_monitor; + // The Windows pointer message hook; dropping it unhooks when the event loop returns. + #[cfg(target_os = "windows")] + let tablet_monitor_win = std::cell::OnceCell::new(); + #[cfg(target_os = "windows")] + let tablet_monitor_win = &tablet_monitor_win; // Read the displays' ICC profiles while the window opens (colour-managed canvas; `None` // where the platform has no reader). @@ -456,7 +692,9 @@ fn main() -> eframe::Result { } 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. - gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone()); + let wanted_adapter = + gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref())); + gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone()); // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers. if gpu_startup::read_low_latency(services::prefs_file().as_deref()) { @@ -499,7 +737,15 @@ fn main() -> eframe::Result { services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some()); } } - let mut app = PhotocraftApp::new(Session::new(), services); + #[cfg(target_os = "linux")] + let session = { + let mut session = Session::new(); + session.print_spooler = print_portal::spooler(); + session + }; + #[cfg(not(target_os = "linux"))] + let session = Session::new(); + let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; @@ -544,9 +790,19 @@ fn main() -> eframe::Result { info.canvas = "cpu".into(); if let Some(rs) = cc.wgpu_render_state.clone() { app.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + app.perf.gpu_info.set_available(&rs.available_adapters); + // Every egui texture uploads through this adapter, whichever canvas draws: + // Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs). + photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info()); let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu; if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu { - app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into()); + // Keep an earlier note, such as a requested adapter that couldn't be used. + let software = "No compatible hardware graphics adapter; using software graphics."; + let fallback = &mut app.perf.gpu_info.fallback; + *fallback = Some(match fallback.take() { + Some(earlier) => format!("{earlier}. {software}"), + None => software.into(), + }); } if !software_window && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu @@ -555,8 +811,9 @@ fn main() -> eframe::Result { { app.set_wgpu(rs); } else { - // The window still draws with wgpu: record its errors instead of panicking. - let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device); + // The window still draws with wgpu: record its errors instead of panicking, + // and notice when the device is lost (#3438). + app.watch_window_device(&rs.device); } } let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| { @@ -623,8 +880,13 @@ fn main() -> eframe::Result { if let Some(monitor) = tablet::install_macos(&app.stylus.feed) { let _ = tablet_monitor.set(monitor); } + #[cfg(target_os = "windows")] + if let Some(monitor) = tablet::install_windows() { + let _ = tablet_monitor_win.set(monitor); + } #[cfg(target_os = "linux")] tablet::spawn_x11(&app.stylus.feed, display); + app.services.right_button_down = tablet::right_button_service(); // Paths on the command line (Linux/Windows file associations, `photocraft a.psd`). app.open_paths(&files); if !unreadable_paths.is_empty() { @@ -690,6 +952,19 @@ fn main() -> eframe::Result { result } +/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock +/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file, +/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held +/// for a moment. +#[cfg(test)] +pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile. +#[cfg(test)] +pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { #[test] diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs index 8e33869696..c210e096ec 100644 --- a/apps/photocraft/src/monitor_profile.rs +++ b/apps/photocraft/src/monitor_profile.rs @@ -495,6 +495,7 @@ mod tests { #[cfg(unix)] #[test] fn a_hung_helper_is_stopped_and_big_replies_dont_block() { + let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let t0 = std::time::Instant::now(); let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err(); assert!(e.contains("took longer than"), "{e}"); diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs index 42588e2bc4..d16432193b 100644 --- a/apps/photocraft/src/portal_save.rs +++ b/apps/photocraft/src/portal_save.rs @@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option { Some(PathBuf::from(std::ffi::OsString::from_vec(out))) } +/// Which file type's extensions belong on the name the dialog returned: the one it reported, or, +/// when the dialog offered only one type, that one. It reports no pick then, and the name it was +/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as +/// `Swatches.aco`. `None` when the answer names no type this dialog offered. +fn chosen_type(filters: &[Filter], picked: Option) -> Option { + match picked { + Some(i) if i < filters.len() => Some(i), + None if filters.len() == 1 => Some(0), + _ => None, + } +} + /// Save As on Linux: the portal's dialog, with the name given the extension of the file type /// picked. When that changes the name to one that already exists, the dialog opens again under /// the new name, so the portal asks before replacing it (it asked only about the typed name). @@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul loop { let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label)); - // With one type offered there is nothing to pick, and a typed extension is the user's. - let exts: &[String] = match picked { - Some(i) if req.filters.len() > 1 => &req.filters[i].1, - _ => &[], + // A typed extension is the user's own: `with_chosen_type` keeps one it recognises. + let exts: &[String] = match chosen_type(&req.filters, picked) { + Some(i) => &req.filters[i].1, + None => &[], }; let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); if path == saved.path || !path.exists() { diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..7a939639af 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std: /// once the user is done with it (here after 300 ms). fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { use std::io::BufRead; + let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let mut daemon = std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + drop(spawns); let mut address = String::new(); std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; let address = address.trim().to_string(); let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); let seen: Seen = Arc::default(); let log = seen.clone(); + // A stream only gets the messages that arrive after it exists: made in the thread, it could + // miss a `SaveFile` sent before the thread got going, and the call would wait forever. + let messages = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in messages { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { @@ -230,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() { assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase"))); daemon.kill().ok(); } + +/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and +/// that one type gives it its extension instead of a file named `Swatches` being written. +#[test] +fn a_single_file_type_gives_the_bare_name_its_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") }; + assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco"))); + daemon.kill().ok(); +} + +/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`, +/// so the rule itself is checked here too, which needs none. +#[test] +fn the_type_that_gives_the_extension_is_the_one_picked() { + let one: Vec = vec![("Color Swatches".into(), vec!["aco".into()])]; + // One type offered: the dialog reports no pick, and it was given the stem. + assert_eq!(chosen_type(&one, None), Some(0)); + assert_eq!(chosen_type(&one, Some(0)), Some(0)); + let many: Vec = request("test").filters; + assert_eq!(chosen_type(&many, Some(1)), Some(1)); + // Several types and no pick: nothing to add, as before. + assert_eq!(chosen_type(&many, None), None); + // An answer naming a type this dialog never offered is not a pick either. + assert_eq!(chosen_type(&many, Some(99)), None); +} diff --git a/apps/photocraft/src/print_portal.rs b/apps/photocraft/src/print_portal.rs new file mode 100644 index 0000000000..0eef02a01c --- /dev/null +++ b/apps/photocraft/src/print_portal.rs @@ -0,0 +1,239 @@ +//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print +//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP +//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and +//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the +//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a +//! `Request` object's `Response` signal, which this module waits for on its own thread so the +//! window keeps painting while the dialog is open; the outcome goes to the log. +//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html + +#[cfg(target_os = "linux")] +use photocraft_engine::print_cmds::{PrintJob, Spooler}; + +/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself +/// is looked up per job, so a missing one costs nothing at start-up. +#[cfg(target_os = "linux")] +pub fn spooler() -> Option { + let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok(); + if setting.as_deref() == Some("0") { + return None; + } + Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) }) +} + +/// Opens the system print dialog for the job and returns as soon as it is up. +#[cfg(target_os = "linux")] +fn print(job: &PrintJob) -> Result { + let conn = zbus::blocking::connection::Builder::session() + .and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) + .map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?; + let proxy = portal(&conn)?; + let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?; + // The document goes to a private temporary file (0600, created atomically by `tempfile`), + // not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal + // thread until the dialog is done, and a failure to start that thread drops it here. + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?; + std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?; + let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job)); + std::thread::Builder::new() + .name("print-portal".into()) + .spawn(move || { + let outcome = dialog(&conn, &title, &settings, &page_setup, file.path()); + drop(file); + match outcome { + Ok(what) => log::info!("print portal (v{version}): {what}"), + Err(e) => log::warn!("print portal (v{version}): {e}"), + } + }) + .map_err(|e| format!("cannot start the print thread: {e}"))?; + Ok("the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn portal(conn: &zbus::blocking::Connection) -> Result, String> { + zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print") + .map_err(|e| e.to_string()) +} + +/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal. +#[cfg(target_os = "linux")] +fn dialog( + conn: &zbus::blocking::Connection, + title: &str, + settings: &[(&'static str, Seed)], + page_setup: &[(&'static str, Seed)], + pdf: &std::path::Path, +) -> Result { + use std::collections::HashMap; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + type Dict<'a> = HashMap<&'a str, Value<'a>>; + fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> { + pairs + .iter() + .map(|(k, v)| { + let value = match v { + Seed::Text(t) => Value::from(t.clone()), + Seed::Mm(mm) => Value::from(*mm), + }; + (*k, value) + }) + .collect() + } + let proxy = portal(conn)?; + let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?; + let wait = |token: &str| -> Result, String> { + let path = request_path(&sender, token); + let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + request.receive_signal("Response").map_err(|e| e.to_string()) + }; + let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result, String> { + let message = signals.next().ok_or("the print portal went away")?; + let (code, results): (u32, HashMap) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?; + match code { + 0 => Ok(results), + 1 => Err("cancelled in the print dialog".into()), + _ => Err("the print dialog failed".into()), + } + }; + + let token = format!("photocraft_prepare_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + let _: OwnedObjectPath = + proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?; + let results = response(signals)?; + let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?; + + let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?; + let token = format!("photocraft_print_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + options.insert("token", Value::from(prepared)); + let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file)); + let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?; + response(signals)?; + Ok("printed through the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0); + t ^ N.fetch_add(1, Ordering::Relaxed) +} + +/// The request object the portal answers on: the sender's unique name without its colon and +/// with dots as underscores, then the handle token. +fn request_path(unique_name: &str, token: &str) -> String { + format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_")) +} + +/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are +/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum Seed { + Text(String), + Mm(f64), +} + +fn text(s: &str) -> Seed { + Seed::Text(s.to_string()) +} + +/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's +/// print settings take the PWG name; its page setup finds the size by the PPD name. +const PAPERS: [(&str, &str, f64, f64); 8] = [ + ("na_letter", "Letter", 215.9, 279.4), + ("na_legal", "Legal", 215.9, 355.6), + ("na_ledger", "Ledger", 279.4, 431.8), + ("iso_a3", "A3", 297.0, 420.0), + ("iso_a4", "A4", 210.0, 297.0), + ("iso_a5", "A5", 148.0, 210.0), + ("na_index-4x6", "4x6", 101.6, 152.4), + ("na_5x7", "5x7", 127.0, 177.8), +]; + +const MM_PER_PT: f64 = 25.4 / 72.0; + +type Paper = (&'static str, &'static str, f64, f64); + +/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in +/// millimetres. +fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) { + let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT); + let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0); + (row, w, h) +} + +fn orientation(landscape: bool) -> Seed { + text(if landscape { "landscape" } else { "portrait" }) +} + +/// `PreparePrint`'s settings (GtkPrintSettings keys, string values). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))]; + match row { + Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))), + None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]), + } + v +} + +/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD +/// name when it has one, else a custom size named by its dimensions. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))]; + match row { + Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))), + None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))), + } + v +} + +#[cfg(target_os = "linux")] +fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> { + settings_for(job.copies, job.paper, job.landscape) +} + +#[cfg(target_os = "linux")] +fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> { + page_setup_for(job.paper, job.landscape) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_follows_the_portal_rule() { + assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7"); + } + + #[test] + fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() { + // A4 landscape as the engine lays it out: width > height, still A4. + let s = settings_for(2, (841.89, 595.28), true); + assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]); + let p = page_setup_for((841.89, 595.28), true); + assert_eq!(p[0], ("Orientation", text("landscape"))); + let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") }; + let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") }; + assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(p[3], ("PPDName", text("A4"))); + assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter"))); + assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter"))); + assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6"))); + // 100 x 200 mm is nobody's standard. + let custom = settings_for(1, (283.46, 566.93), false); + assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]); + assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm"))); + } +} diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..cb7dae9fc5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -1,12 +1,10 @@ //! Native platform services: file dialogs (rfd), filesystem, codecs. -use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS}; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Layer, Size}; +use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; +use photocraft_doc::Document; use photocraft_format::RecoveryStore; -use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -17,12 +15,13 @@ use std::sync::Arc; mod recovery_tests; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets +/// Photoshop brushes (.abr), gradients (.grd), 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", "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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", - "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "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", "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", "svg", "svgz", "aco", "act", "ase", + "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -36,16 +35,50 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec { extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() } -/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to -/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens. +/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder +/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of +/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native +/// picker defaults to another filter. +const OPEN_FILTERS: &[(&str, &[&str])] = &[ + ("PhotoCraft", &["pcraft"]), + ("Photoshop", &["psd", "psb", "psdt"]), + ("Affinity", &["af", "afdesign", "afphoto", "afpub"]), + ("Krita", &["kra", "krz"]), + ("GIMP", &["xcf", "xcfgz", "gz"]), + ("OpenRaster", &["ora"]), + ("Paint.NET", &["pdn"]), + ("SVG", SVG_EXTS), + ("PNG", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("HEIF", &["heic", "heif", "hif"]), + ("TIFF", &["tif", "tiff"]), + ("WebP", &["webp"]), + ("GIF", &["gif"]), + ("BMP", &["bmp"]), + ("Targa", &["tga"]), + ("ICO", &["ico"]), + ("QOI", &["qoi"]), + ("PCX", &["pcx"]), + ("DDS", &["dds"]), + ("XPM", &["xpm"]), + ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]), + ("OpenEXR", &["exr"]), + ("Radiance HDR", &["hdr"]), + ("FITS", &["fits", "fit", "fts"]), + ("DPX and Cineon", &["dpx", "cin"]), + ("Amiga IFF", &["iff", "lbm", "ilbm"]), + ("DICOM", &["dcm", "dicom", "dc3"]), + ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]), + ("Brushes", &["abr"]), + ("Gradients", &["grd"]), + ("Swatches", &["aco", "act", "ase"]), + ("Keyboard Shortcuts", &["kys"]), +]; + fn open_filters() -> Vec<(&'static str, Vec)> { - vec![ - ("All Formats", open_filter_extensions(OPEN_EXTS)), - ("PhotoCraft", open_filter_extensions(&["pcraft"])), - ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra", "krz"])), - ("SVG", open_filter_extensions(SVG_EXTS)), - ] + std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS))) + .chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts)))) + .collect() } /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's @@ -68,11 +101,14 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), + // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327). + ("DDS", &["dds"]), + ("Cineon", &["cin"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog /// never swaps their extension for a document format's. -const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])]; +const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])]; /// 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)> { @@ -160,17 +196,14 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, let dialog = if let Some(exts) = extensions { dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::>())) } else { - open_filters() - .into_iter() - .fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) - .add_filter("Paint.NET", &open_filter_extensions(&["pdn"])) + open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) }; if multiple { let picked = dialog.pick_files(); - Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) }) + Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) }) } else { let picked = dialog.pick_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) }) + Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) }) } } FileDialogRequest::Folder { initial_dir } => { @@ -178,7 +211,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, dialog = dialog.set_directory(dir); } let picked = dialog.pick_folder(); - Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) + Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) }) } FileDialogRequest::Save { suggested } => { let filters = save_filters(&suggested); @@ -213,10 +246,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, .map_err(|e| e.to_string()) .and_then(|conn| crate::portal_save::save_as(&conn, req)); match portal { - Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())), + Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)), Err(e) => { log::info!("portal save dialog unavailable ({e}), using rfd"); - dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) + dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) } } }) @@ -225,7 +258,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, { let _ = filters; let picked = dialog.save_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) }) } } }; @@ -239,12 +272,26 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, } } -fn path_of(file: &rfd::FileHandle) -> String { - file.path().to_string_lossy().into_owned() +/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid +/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths +/// are to be written to. +fn answer<'a>(paths: impl IntoIterator, saving: bool, make: impl FnOnce(Vec) -> FileDialogAnswer) -> FileDialogAnswer { + match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() { + Ok(paths) => make(paths), + Err(e) => FileDialogAnswer::Unavailable(e), + } +} + +/// [`answer`] for a dialog that picks one path. +fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer { + match photocraft_ui_egui::file_open::path_text(path, saving) { + Ok(path) => make(path), + Err(e) => FileDialogAnswer::Unavailable(e), + } } /// Runs `future` to completion on this thread, sleeping until it is woken. -fn block_on(future: impl Future) -> T { +pub(crate) fn block_on(future: impl Future) -> T { struct Unpark(std::thread::Thread); impl std::task::Wake for Unpark { fn wake(self: Arc) { @@ -265,7 +312,7 @@ fn block_on(future: impl Future) -> T { /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in /// portable mode, else the platform convention. Everything the app persists lives under it -/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. +/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -280,8 +327,9 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } -/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay -/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]). +/// Crash-recovery autosaves: under the settings directory, except where that is synced with the +/// settings: the Windows roaming profile and the Linux config folder (see +/// [`crate::app_dirs::resolve_recovery`]). fn recovery_dir() -> Option { crate::app_dirs::recovery_dir() } @@ -298,6 +346,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) } +/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path +/// on Windows (where it holds the name's letter case as stored). False when either can't be read. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + +/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file +/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a +/// file system that ignores letter case is the same entry; a hard link is another entry, which a +/// save replaces while the first name keeps the old file. False when the folder can't be read. +fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool { + let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf(); + if !same_file(a, b) || !same_file(&folder(a), &folder(b)) { + return false; + } + let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false }; + if x == y { + return true; + } + let Ok(entries) = std::fs::read_dir(folder(a)) else { return false }; + let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect(); + !(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y)) +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -355,12 +441,13 @@ fn with_store(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R fn recovery_services(dir: Option) -> Services { let recover_dir = dir.clone(); let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new))); - let (s1, s2, s4) = (store.clone(), store.clone(), store.clone()); + let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone()); Services { autosave: Some(Box::new(move |doc: &Arc, revision: u64, path: Option<&str>| { with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))? })), autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())), + flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())), discard_autosave: Some(Box::new(move |id: u64| { let _ = with_store(&s2, |s| s.discard(id)); })), @@ -383,6 +470,17 @@ fn recovery_services(dir: Option) -> Services { } } +/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a +/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the +/// app keeps its documents. +fn guarded_clipboard_get(mut read: impl FnMut() -> Result)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn { + Box::new(move || crate::crash_guard::guard("Paste", &mut read)) +} + +fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn { + Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px))) +} + /// The pixels to paste when the clipboard holds copied files rather than an image (Copy in /// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes, /// upright, as RGBA8. Files that aren't images are skipped after reading only their header. @@ -464,6 +562,7 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); + let clip_unreadable: Rc> = Rc::default(); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -494,9 +593,9 @@ pub fn native(automation: Option) -> }) as photocraft_ui_egui::AutomationCommandFn }); Services { - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { crate::crash_guard::guard("Open", || { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) }) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { @@ -511,6 +610,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), + same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))), automation_read, automation_write, automation_open_pcraft, @@ -526,7 +626,7 @@ pub fn native(automation: Option) -> beep: Some(platform_beep), clipboard_set_image: Some({ let clip = clip.clone(); - Box::new(move |w: u32, h: u32, px: &[u8]| { + guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| { let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, @@ -537,26 +637,47 @@ pub fn native(automation: Option) -> }), clipboard_get_image: Some({ let clip = clip.clone(); - Box::new(move || { - let mut slot = clip.try_borrow_mut().ok()?; + let unreadable = clip_unreadable.clone(); + guarded_clipboard_get(move || { + let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => { + // arboard opens only when some backend works. In a Flatpak on Wayland none + // does (#5031): the sandbox hides the data-control protocol from it, and + // `--socket=fallback-x11` gives no X server while a compositor runs. + let opened = arboard::Clipboard::new(); + unreadable.set(opened.is_err()); + 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. - if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) { - return Some(img); + match cb.get().file_list() { + Ok(paths) => { + if let Some(img) = image_from_files(&paths) { + return Ok(Some(img)); + } + } + Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()), + Err(_) => {} } - if let Ok(img) = cb.get_image() { - return Some((img.width as u32, img.height as u32, img.bytes.into_owned())); + 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()), + } + } } - #[cfg(target_os = "linux")] - return wayland_clipboard_image(); - #[cfg(not(target_os = "linux"))] - None }) }), + clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), @@ -581,77 +702,19 @@ fn platform_beep() { { let _ = winsafe::MessageBeep(winsafe::co::MBP::OK); } - #[cfg(not(target_os = "windows"))] + // The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal + // bell below is silent for an app started from the Finder or the Dock. + #[cfg(target_os = "macos")] + objc2_app_kit::NSBeep(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - // No beep API among this app's other platforms' dependencies: write the terminal bell - // (audible when launched from a terminal, which is the same cue macOS terminals use). + // No beep API among the other platforms' dependencies: write the terminal bell (audible + // when launched from a terminal). use std::io::Write as _; let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush()); } } -/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io). -#[allow(dead_code)] -pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?; - let (w, h) = (img.width(), img.height()); - let depth = match img.sample_type() { - CS::U8 => SampleType::U8, - CS::U16 => SampleType::U16, - _ => SampleType::F32, - }; - let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA); - let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA); - let mode = if gray { - ColorMode::Grayscale - } else if cmyk { - ColorMode::Cmyk - } else { - ColorMode::Rgb - }; - let target = match mode { - ColorMode::Grayscale => ChannelLayout::GrayA, - ColorMode::Cmyk => ChannelLayout::CmykA, - _ => ChannelLayout::Rgba, - }; - let sample = match depth { - SampleType::U8 => CS::U8, - SampleType::U16 => CS::U16, - SampleType::F32 => CS::F32, - }; - let conv = img.convert(target, sample); - let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string()); - let mut doc = Document::new(stem, Size::new(w, h), mode, depth); - doc.icc_profile = img.icc.clone().map(std::sync::Arc::new); - if let Some((x, _)) = img.meta.dpi { - doc.resolution_dpi = x; - } - let mut layer = Layer::raster("Background", doc.pixel_format()); - let data = conv.to_normalized(); - layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data); - doc.layers.push(layer); - Ok(doc) -} - -#[allow(dead_code)] -pub fn export_flat(doc: &Document, path: &str) -> Result, String> { - let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?; - let buf = photocraft_compose::flatten(doc); - let (w, h) = (buf.rect.width(), buf.rect.height()); - let data: Vec = buf.px.iter().flat_map(|p| *p).collect(); - let img = match doc.depth { - SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels), - SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::>()), - SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data), - } - .map_err(|e| e.to_string())?; - let img = match &doc.icc_profile { - Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())), - _ => img, - }; - photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -660,6 +723,31 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily + /// (Open would fail with "No such file", Save would write a second, renamed file). + #[test] + fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() { + #[cfg(unix)] + let bad = { + use std::os::unix::ffi::OsStrExt; + std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png")) + }; + #[cfg(windows)] + let bad = { + use std::os::windows::ffi::OsStringExt; + let mut wide: Vec = "img".encode_utf16().collect(); + wide.push(0xD800); + wide.extend(".png".encode_utf16()); + std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide)) + }; + let good = std::env::temp_dir().join("ok.png"); + let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo); + assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name"))); + assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap())); + let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths); + assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again"))); + } + /// #6121: the file type picked in the save dialog gives the name its extension where the /// dialog left the one it opened with, and only there. #[test] @@ -695,6 +783,57 @@ mod tests { assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd")); } + /// Regression using the real clipboard on an isolated Windows CI runner. + #[cfg(target_os = "windows")] + #[test] + #[ignore = "requires exclusive use of the Windows clipboard"] + fn busy_native_clipboard_preserves_copied_shape() { + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] { + let mut native = native(None); + let services = Services { + clipboard_set_image: native.clipboard_set_image.take(), + clipboard_get_image: native.clipboard_get_image.take(), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded"); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + + // Another thread holds the OS clipboard open beyond arboard's bounded retry window. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + // A non-null window distinguishes this owner from arboard's null-window opens. + let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + drop(guard); + }); + ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + let contention = arboard::Clipboard::new().unwrap().get_image().err(); + let first = app.run(id, json!({})); + release_tx.send(()).unwrap(); + holder.join().unwrap(); + assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}"); + eprintln!("paste while clipboard occupied: {first:?}"); + assert!(first.is_err(), "report contention rather than a successful no-op"); + assert!( + app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()), + "a temporary OS read failure must not discard the copied editable shape" + ); + app.run(id, json!({})).unwrap(); + let state = app.session.active().unwrap(); + assert!( + matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)), + "retry keeps the shape editable" + ); + } + } + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn open_filters_cover_uppercase_and_mixed_case_extensions() { @@ -726,15 +865,42 @@ mod tests { assert!(!globs.iter().any(|glob| matches(glob, "txt"))); let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default(); assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS)); + let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default(); + assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"])); + assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD"))); assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz"))); } + /// #6337: after All Formats, every format File › Open reads has its own filter, and no filter + /// offers an extension All Formats doesn't. + #[test] + fn every_open_format_has_its_own_filter() { + let filters = open_filters(); + assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first"); + let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect(); + let total = named.len(); + named.sort_unstable(); + named.dedup(); + assert_eq!(named.len(), total, "an extension is listed under two filters"); + let mut all = OPEN_EXTS.to_vec(); + all.sort_unstable(); + assert_eq!(named, all, "the named filters cover All Formats exactly"); + let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect(); + for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] { + assert!(names.contains(&name), "{name}"); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] #[test] fn open_filters_keep_literal_extensions_on_windows_and_macos() { assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS); assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]); assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()])); + assert_eq!( + open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts), + Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()]) + ); } #[test] @@ -844,7 +1010,7 @@ mod tests { assert!(format.caps().write, "{name}: .{ext} can't be written"); } } - for ext in ["ico", "gif", "bmp"] { + for ext in ["ico", "gif", "bmp", "dds"] { assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); } assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); @@ -888,6 +1054,50 @@ mod tests { assert_eq!(dialog_file_name("", true), None); } + /// A name in another letter case reaches the same file where the file system ignores case + /// (Windows, and macOS by default), and only there. Two files with the same contents are two. + #[test] + fn same_file_follows_the_file_system() { + let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD")); + std::fs::write(&cat, b"same").unwrap(); + std::fs::write(&dog, b"same").unwrap(); + assert!(same_file(&cat, &cat)); + assert!(!same_file(&cat, &dog)); + assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored"); + assert!(!same_file(&dir.join("missing.psd"), &cat)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A hard link is another entry in the folder, though it reaches the same file: a save to it + /// replaces that entry and leaves the other name on the old file. Another spelling of a name, + /// where letter case is ignored, is the same entry. + #[test] + fn a_hard_link_is_another_entry() { + let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id())); + std::fs::create_dir_all(dir.join("other")).unwrap(); + let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd")); + std::fs::write(&cat, b"cat").unwrap(); + std::fs::hard_link(&cat, &bare).unwrap(); + std::fs::hard_link(&cat, &other).unwrap(); + // Where both names reach one file (one inode on Unix), the folder still lists both. + let one_file = |_: &Path, _: &Path| true; + assert!(!same_entry(&bare, &cat, one_file)); + assert!(!same_entry(&bare, &cat, same_file)); + assert!(!same_entry(&other, &cat, same_file), "another folder"); + assert!(same_entry(&cat, &cat, one_file)); + assert!(same_entry(&cat, &cat, same_file)); + if upper.exists() { + assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored"); + } else { + std::fs::hard_link(&cat, &upper).unwrap(); + assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart"); + } + assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); @@ -1165,4 +1375,29 @@ mod tests { assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255]))); assert!(image_from_files(&[bin]).is_none()); } + + /// #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. + #[test] + fn a_panicking_clipboard_read_is_reported_not_a_crash() { + let panics = Rc::new(Cell::new(true)); + let p = panics.clone(); + let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) }); + let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() }); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + + let error = app.run("edit.paste", json!({})).unwrap_err(); + assert!(error.contains("Paste failed"), "{error}"); + assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged"); + + panics.set(false); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works"); + + let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug")); + assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed")); + } } diff --git a/apps/photocraft/src/services/recovery_tests.rs b/apps/photocraft/src/services/recovery_tests.rs index b1bd2d51c0..23191ed7f9 100644 --- a/apps/photocraft/src/services/recovery_tests.rs +++ b/apps/photocraft/src/services/recovery_tests.rs @@ -1,5 +1,6 @@ use super::*; -use photocraft_color::Color; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::Size; use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery}; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index d2cd65c678..953da20745 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -3,8 +3,9 @@ //! copy of the program. //! //! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS -//! drops it when the process ends, crash included), listens on a loopback port and writes the port -//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its +//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak +//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit +//! token to `instance.json`. A later launch that finds the lock taken sends its //! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and //! brings the window forward. Explorer starts one process per file when several are opened at once; //! the lock decides the race, so they all end up as tabs in one window. @@ -26,7 +27,11 @@ use std::fs::{File, OpenOptions}; use std::io::{BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{SyncSender, sync_channel}; use std::sync::{Arc, Mutex, PoisonError}; @@ -38,6 +43,9 @@ use serde_json::{Value, json}; const LOCK_FILE: &str = "instance.lock"; const INFO_FILE: &str = "instance.json"; +/// The Unix socket a sandboxed primary listens on, in the app's runtime directory. +#[cfg(unix)] +const SOCKET_FILE: &str = "instance.sock"; /// More paths than this in one request is not a file manager. const MAX_PATHS: usize = 4096; /// Launches served at once; a stalled client holds only its own slot, never the next launch. @@ -59,6 +67,45 @@ pub struct Timing { /// Generous enough for a primary that is still opening its window. pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) }; +/// How the primary listens for later launches. +#[derive(Clone, Debug, PartialEq)] +pub enum Transport { + /// A loopback port (the default): private to the machine, reachable by every process on it. + Loopback, + /// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each + /// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never + /// reaches the first (#1540). `$XDG_RUNTIME_DIR/app//` is the same directory in every + /// sandbox of the app, and the user's alone (0700). + #[cfg(unix)] + Socket(PathBuf), +} + +/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is +/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port. +pub fn transport() -> Transport { + #[cfg(unix)] + if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) { + return Transport::Socket(path); + } + Transport::Loopback +} + +/// `$XDG_RUNTIME_DIR/app//instance.sock`, when both are set. +#[cfg(unix)] +fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option { + let id = flatpak_id.filter(|v| !v.is_empty())?; + let dir = runtime_dir.filter(|v| !v.is_empty())?; + Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE)) +} + +/// Where the running primary listens, as `instance.json` records it. +#[derive(Clone, Debug, PartialEq)] +enum Endpoint { + Port(u16), + #[cfg(unix)] + Socket(PathBuf), +} + /// Outcome of [`claim`]. pub enum Launch { /// This process is the running instance; keep [`Primary`] alive and attach it to the app. @@ -82,10 +129,71 @@ pub struct LaunchContext<'a> { pub control: bool, /// Paths that aren't valid Unicode: this launch reports them. pub unreadable_paths: bool, - /// Options that only apply to the process they're given to (`--safe-gpu`, …). + /// Options that only apply to the process they're given to (`--safe-gpu`, …), including the + /// ones this launch's environment sets (see [`env_launch_options`]). pub launch_options: &'a [&'static str], } +/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for +/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for +/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this +/// process alone. +const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [ + ("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on), + ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on), + ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on), + ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on), + ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set), + ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set), +]; + +/// Whether a variable's value asks this launch for its own option. Each rule matches how that +/// variable is read where it takes effect, so a value ignored there keeps no process here either. +type ValueRule = fn(&str) -> bool; + +/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and +/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE` +/// changes nothing there and so needs no process of its own here. +fn present_mode_is_on(value: &str) -> bool { + crate::presentation::parse(Some(value), None).0.mode.is_some() +} + +/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`. +fn frame_latency_is_on(value: &str) -> bool { + crate::presentation::parse(None, Some(value)).0.latency.is_some() +} + +/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the +/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here. +fn stroke_trace_is_on(value: &str) -> bool { + !matches!(value.trim(), "" | "0" | "off" | "false") +} + +/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is +/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does +/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too. +fn flag_is_on(value: &str) -> bool { + !value.is_empty() && value != "0" +} + +/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty +/// root is read as no root unless `--control` is given, and that flag keeps its own process already. +fn path_is_set(value: &str) -> bool { + !value.is_empty() +} + +/// The per-process options this launch's environment sets, named as the flags they stand for. A +/// launch that sets only variables would otherwise hand its files to the running instance, which +/// never sees the override, and exit. +pub fn env_launch_options() -> Vec<&'static str> { + env_launch_options_from(|name| std::env::var_os(name)) +} + +/// [`env_launch_options`] with the reader given, so the rule is testable without the environment. +fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec<&'static str> { + ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect() +} + /// Why this launch runs as its own instance, or `None` to take part in single-instance. pub fn skip_reason(c: &LaunchContext) -> Option { if !c.per_user_settings { @@ -110,6 +218,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option { pub struct Primary { lock: Mutex>, shared: Arc, + /// The socket file to remove on the first shutdown (a Unix socket transport only); taken + /// then, so the second call (`Drop`) can't remove the socket the next primary has bound. + #[cfg(unix)] + socket: Mutex>, } struct Shared { @@ -151,6 +263,10 @@ impl Primary { self.shared.closed.store(true, Ordering::Release); // Dropping the senders refuses every connection still waiting for the UI. self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear(); + #[cfg(unix)] + if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = std::fs::remove_file(path); + } drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take()); } } @@ -211,9 +327,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch { claim_with(dir, paths, TIMING) } -/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock -/// (free once a quitting or crashed primary is gone) and then the running primary. +/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]). pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { + claim_in(dir, paths, timing, &transport()) +} + +/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free +/// once a quitting or crashed primary is gone) and then the running primary, wherever +/// `instance.json` says it listens. +pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch { if let Err(e) = std::fs::create_dir_all(dir) { return Launch::Standalone(format!("{}: {e}", dir.display())); } @@ -227,7 +349,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { loop { match lock.try_lock() { Ok(()) => { - return match serve(dir, lock, timing) { + return match serve(dir, lock, timing, transport) { Ok(primary) => Launch::Primary(primary), Err(e) => Launch::Standalone(e), }; @@ -235,8 +357,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { Err(std::fs::TryLockError::WouldBlock) => {} Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())), } - if let Some((port, token)) = read_info(dir) { - match send(port, &token, paths, deadline) { + if let Some((endpoint, token)) = read_info(dir) { + match send(&endpoint, &token, paths, deadline) { Ok(()) => return Launch::Forwarded, Err(e) => last = e, } @@ -248,11 +370,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { } } -fn serve(dir: &Path, lock: File, timing: Timing) -> Result { - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?; - let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port(); +fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result { + let (listener, endpoint) = Listener::bind(transport)?; let token = generate_token().map_err(|e| e.to_string())?; - write_info(dir, port, &token)?; + write_info(dir, &endpoint, &token)?; + #[cfg(unix)] + let socket = match &endpoint { + Endpoint::Socket(path) => Some(path.clone()), + Endpoint::Port(_) => None, + }; let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) }); let listening = Arc::clone(&shared); let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || { @@ -260,7 +386,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result { let clients = Arc::new(AtomicUsize::new(0)); loop { let stream = match listener.accept() { - Ok((stream, _)) => stream, + Ok(stream) => stream, // Back off instead of spinning when accept keeps failing (e.g. out of descriptors). Err(_) => { std::thread::sleep(Duration::from_millis(100)); @@ -288,11 +414,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result { } }); spawned.map_err(|e| format!("single-instance listener thread: {e}"))?; - Ok(Primary { lock: Mutex::new(Some(lock)), shared }) + Ok(Primary { + lock: Mutex::new(Some(lock)), + shared, + #[cfg(unix)] + socket: Mutex::new(socket), + }) +} + +/// The primary's listener on either transport. +enum Listener { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), +} + +impl Listener { + fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> { + match transport { + Transport::Loopback => { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?; + let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port(); + Ok((Listener::Tcp(listener), Endpoint::Port(port))) + } + #[cfg(unix)] + Transport::Socket(path) => { + let at = |e: std::io::Error| format!("{}: {e}", path.display()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(at)?; + } + // This process holds the lock, so a file already there is a crashed primary's. + let _ = std::fs::remove_file(path); + let listener = UnixListener::bind(path).map_err(at)?; + // The runtime directory is the user's alone already; keep the socket so too. + std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?; + Ok((Listener::Unix(listener), Endpoint::Socket(path.clone()))) + } + } + } + + fn accept(&self) -> std::io::Result { + match self { + Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)), + #[cfg(unix)] + Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)), + } + } +} + +/// One connection, on either transport. +enum Stream { + Tcp(TcpStream), + #[cfg(unix)] + Unix(UnixStream), +} + +impl Stream { + /// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate). + fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result { + match endpoint { + Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp), + #[cfg(unix)] + Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix), + } + } + + fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.set_read_timeout(dur), + #[cfg(unix)] + Stream::Unix(s) => s.set_read_timeout(dur), + } + } + + fn set_write_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.set_write_timeout(dur), + #[cfg(unix)] + Stream::Unix(s) => s.set_write_timeout(dur), + } + } + + /// One read through a shared reference (both socket types read through `&self`). + fn read_once(&self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => (&*s).read(buf), + #[cfg(unix)] + Stream::Unix(s) => (&*s).read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => s.write(buf), + #[cfg(unix)] + Stream::Unix(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.flush(), + #[cfg(unix)] + Stream::Unix(s) => s.flush(), + } + } } /// Read one request, hand its paths to the UI and answer `ok` only if the UI took them. -fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) { +fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) { let taken = match read_request(&stream, timing.request) { Some(line) => match parse_request(&line, token) { Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)), @@ -308,7 +540,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) /// A reader that fails once `until` passes, however slowly the bytes trickle in. struct Deadline<'a> { - stream: &'a TcpStream, + stream: &'a Stream, until: Instant, } @@ -317,25 +549,30 @@ impl Read for Deadline<'_> { let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()); let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?; self.stream.set_read_timeout(Some(left))?; - let mut stream = self.stream; - stream.read(buf) + self.stream.read_once(buf) } } /// The request line, if it arrives complete within `total`. -fn read_request(stream: &TcpStream, total: Duration) -> Option { +fn read_request(stream: &Stream, total: Duration) -> Option { let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total }); let mut line = String::new(); matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line) } -/// Port and token, written to a temporary file and renamed so a reader never sees half of it. +/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it. /// On Unix the file is the user's alone (0600): another local user who read the token could make /// this app open any path. (Windows keeps the per-user ACL of the settings folder.) -fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> { +fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> { let path = dir.join(INFO_FILE); let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); - let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string(); + let mut body = json!({"token": token, "pid": std::process::id()}); + match endpoint { + Endpoint::Port(port) => body["port"] = json!(port), + #[cfg(unix)] + Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()), + } + let body = body.to_string(); // A leftover from a crash could carry wider permissions; the mode applies only on create. let _ = std::fs::remove_file(&tmp); let mut options = OpenOptions::new(); @@ -349,10 +586,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> { }) } -fn read_info(dir: &Path) -> Option<(u16, String)> { +fn read_info(dir: &Path) -> Option<(Endpoint, String)> { let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?; + let token = v.get("token")?.as_str()?.to_string(); + #[cfg(unix)] + if let Some(socket) = v.get("socket").and_then(Value::as_str) { + return Some((Endpoint::Socket(PathBuf::from(socket)), token)); + } let port = u16::try_from(v.get("port")?.as_u64()?).ok()?; - Some((port, v.get("token")?.as_str()?.to_string())) + Some((Endpoint::Port(port), token)) } /// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI. @@ -370,11 +612,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec, Duration)> { Some((paths, wait)) } -/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them. -fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> { +/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them. +fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> { let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?; - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); - let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?; + let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?; // The primary must answer before our deadline, so it waits a little less than we do. let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX); let request = json!({"token": token, "open": paths, "wait_ms": wait_ms}); @@ -435,6 +676,21 @@ mod tests { } } + /// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104). + #[cfg(unix)] + fn short_socket_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE) + } + + /// The loopback port the primary under `dir` listens on. + fn port_of(dir: &Path) -> u16 { + match read_info(dir).unwrap().0 { + Endpoint::Port(port) => port, + #[cfg(unix)] + Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()), + } + } + #[test] fn second_launch_hands_its_paths_to_the_first() { let dir = temp_dir("handoff"); @@ -496,7 +752,7 @@ mod tests { // instance.json from a process that is gone; the lock is free. std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap(); let primary = primary(&dir); - assert!(read_info(&dir).is_some_and(|(port, _)| port != 1)); + assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1))); drop(primary); // Released with the handle, as on process exit. assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_))); @@ -516,10 +772,10 @@ mod tests { primary.shutdown(); assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary"); // After shutdown new connections are refused too. - let (port, token) = read_info(&dir).unwrap(); + let (endpoint, token) = read_info(&dir).unwrap(); drop(primary); let short = Instant::now() + Duration::from_millis(300); - assert!(send(port, &token, &["b.psd".into()], short).is_err()); + assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err()); let _ = std::fs::remove_dir_all(&dir); } @@ -557,7 +813,7 @@ mod tests { let dir = temp_dir("stalled"); let primary = primary(&dir); let mut ui = primary.connect(&egui::Context::default()); - let (port, _) = read_info(&dir).unwrap(); + let port = port_of(&dir); // Connects and never sends a line (each would otherwise hold the listener). let stalled: Vec = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect(); let started = Instant::now(); @@ -575,7 +831,7 @@ mod tests { fn a_trickling_client_is_cut_off_at_the_request_deadline() { let dir = temp_dir("trickle"); let primary = primary(&dir); - let (port, _) = read_info(&dir).unwrap(); + let port = port_of(&dir); let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); let started = Instant::now(); let mut cut_off = false; @@ -602,7 +858,7 @@ mod tests { let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); std::fs::write(&tmp, "{}").unwrap(); std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap(); - write_info(&dir, 1, &generate_token().unwrap()).unwrap(); + write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap(); let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); let _ = std::fs::remove_dir_all(&dir); @@ -616,7 +872,7 @@ mod tests { let held = File::create(dir.join(LOCK_FILE)).unwrap(); held.lock().unwrap(); let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); - write_info(&dir, closed, &generate_token().unwrap()).unwrap(); + write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap(); assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); drop(held); let _ = std::fs::remove_dir_all(&dir); @@ -645,4 +901,121 @@ mod tests { let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); } + + /// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime + /// directory, which every sandbox of the app shares, with the same lock, token and reply. + #[cfg(unix)] + #[test] + fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("socket"); + // Short: a Unix socket path is limited to about 100 bytes (macOS: 104). + let socket = short_socket_path("a"); + let transport = Transport::Socket(socket.clone()); + let primary = match claim_in(&dir, &[], FAST, &transport) { + Launch::Primary(p) => p, + _ => panic!("first launch should be primary"), + }; + assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600); + let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap(); + assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap())); + assert!(info.get("port").is_none()); + let mut ui = primary.connect(&egui::Context::default()); + let paths = ["/home/a/one.psd".to_string()]; + let (d, p, t) = (dir.clone(), paths.clone(), transport.clone()); + let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t)); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]); + assert!(matches!(second.join().unwrap(), Launch::Forwarded)); + // The token still gates the socket. + let (endpoint, _) = read_info(&dir).unwrap(); + assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err()); + primary.shutdown(); + assert!(!socket.exists(), "shutdown removes the socket file"); + // The next primary binds the same path; the old one's drop (a second shutdown) must leave it. + let next = match claim_in(&dir, &[], FAST, &transport) { + Launch::Primary(p) => p, + _ => panic!("the lock is free again"), + }; + drop(primary); + assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket"); + drop(next); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced. + #[cfg(unix)] + #[test] + fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() { + use std::os::unix::fs::FileTypeExt; + let dir = temp_dir("stale-socket"); + let socket = short_socket_path("b"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(socket.parent().unwrap()).unwrap(); + std::fs::write(&socket, b"not a socket").unwrap(); + write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap(); + let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) { + Launch::Primary(p) => p, + Launch::Forwarded => panic!("nothing was listening"), + Launch::Standalone(e) => panic!("{e}"), + }; + assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket()); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(socket.parent().unwrap()); + } + + /// The socket transport is chosen only inside a Flatpak with a runtime directory. + #[cfg(unix)] + #[test] + fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() { + use std::ffi::OsStr; + let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000")); + assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock"))); + assert_eq!(sandbox_socket(None, Some(run)), None); + assert_eq!(sandbox_socket(Some(id), None), None); + assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None); + assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None); + } + + /// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch + /// that sets only variables has per-process options, so it keeps its own process rather than + /// handing its files to the running instance, which never sees the override. + #[test] + fn an_environment_override_counts_as_a_process_option() { + let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into())); + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]); + assert_eq!( + with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]), + vec!["--stroke-trace", "--automation-write-root"] + ); + // Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way + // it is read where it takes effect, not by one rule shared across all six. + assert!(with(&[]).is_empty()); + assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); + // The trace is off at `off` and `false` as well, and a value the presentation parser rejects + // is ignored there, so neither keeps a process of its own here. + assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty()); + assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty()); + // Spaces are part of the value only where the reader keeps them: the trace and the two + // presentation values are trimmed where they take effect, the menu flag and a path are not. + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]); + assert_eq!( + with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]), + vec!["--in-window-menus", "--automation-write-root"] + ); + // A whitespace-only menu value is not empty, so it enables the menus there and here too. + assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]); + // `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too. + assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]); + let base = LaunchContext { + per_user_settings: true, + new_instance_flag: false, + new_instance_env: None, + control: false, + unreadable_paths: false, + launch_options: &[], + }; + let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base }); + assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}"); + } } diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index fc063d2fbc..19ba28996c 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -1,10 +1,12 @@ -//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79). +//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and +//! barrel button on Windows (#5905). //! -//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit -//! local event monitor; XInput2 raw events on a second X connection) and this module writes each -//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's -//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards -//! `WM_POINTER` pressure as touch force. +//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit +//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes +//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's +//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure +//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel +//! button through a thread-local message hook beside winit. //! //! Wayland compositors give a pen only to clients that bind the tablet protocol //! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs @@ -19,15 +21,36 @@ use photocraft_tablet::Sample; use photocraft_ui_egui::stylus::{PenSample, StylusFeed}; /// A tablet sample as the UI's pen sample. +#[allow(dead_code)] pub fn pen_sample(s: Sample) -> PenSample { PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized() } /// Writes tablet samples into `feed` (`None` = a mouse). +#[allow(dead_code)] pub fn sink(feed: StylusFeed) -> impl Fn(Option) + Send + 'static { move |s| feed.set(s.map(pen_sample)) } +/// Install the Windows pointer message hook (main thread; keep the result until the event loop +/// returns). On failure this logs why: the pen barrel button is then missed. +#[cfg(target_os = "windows")] +pub fn install_windows() -> Option { + photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() +} + +/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which +/// reports the logical secondary button (swapped buttons included) for the app's own window only. +#[cfg(target_os = "windows")] +pub fn right_button_service() -> Option { + Some(Box::new(photocraft_tablet::windows::is_barrel_button_down)) +} + +#[cfg(not(target_os = "windows"))] +pub fn right_button_service() -> Option { + None +} + /// Install the AppKit tablet monitor (main thread, once winit created the shared application: /// from eframe's app creation, not before the event loop). Keep the result until the event loop /// returns. On failure this logs why: pens then paint like a mouse. diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs index e418049d07..56c9be1b6a 100644 --- a/apps/photocraft/src/window_frame.rs +++ b/apps/photocraft/src/window_frame.rs @@ -20,6 +20,11 @@ //! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work //! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move. //! +//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the +//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar +//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard +//! instead stops the window one pixel short of the monitor, as other custom-frame apps do. +//! //! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and //! winsafe can't wrap a raw handle without it, so the window is found by its handle instead. @@ -29,7 +34,20 @@ pub use platform::service; mod platform { use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; use photocraft_ui_egui::WindowFrameFn; - use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + use winsafe::prelude::Handle; + use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co}; + + /// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive. + type Rect = (i32, i32, i32, i32); + + /// A monitor edge. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Edge { + Left, + Top, + Right, + Bottom, + } /// A guard that keeps the window borderless each frame, or `None` when the app uses the /// system title bar (the window is decorated) or its handle can't be found. @@ -99,8 +117,11 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some((left, top, right, bottom)) = - clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) + let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)); + // The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window + // covers the whole monitor, which a trimmed maximize no longer does. + let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work) + .or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten()) else { return; }; @@ -153,6 +174,69 @@ mod platform { (corrected != rect).then_some(corrected) } + /// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app. + fn covers(rect: Rect, monitor: Rect) -> bool { + rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3 + } + + /// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary + /// monitor's `Shell_SecondaryTrayWnd`), shown or hidden. + fn taskbar_edges(monitor: Rect) -> Vec { + let mut edges = Vec::new(); + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let mut after: Option = None; + // One taskbar per monitor; the bound only guards against a misbehaving shell. + for _ in 0..64 { + let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else { + break; + }; + if let Ok(r) = tray.GetWindowRect() { + edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor)); + } + after = Some(tray); + } + } + edges + } + + /// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre, + /// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its + /// edge with a sliver left on screen, so its centre may lie outside the monitor. + fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option { + let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3)); + let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3)); + if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb { + return None; + } + // Doubled centre and distances, so nothing rounds. + let (cx, cy) = (tl + tr, tt + tb); + [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)] + .into_iter() + .min_by_key(|&(_, distance)| distance) + .map(|(edge, _)| edge) + } + + /// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a + /// taskbar reserves no work area (it auto-hides), so the shell still reveals it there. + /// `None` when there is nothing to trim. + fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option { + if !covers(rect, monitor) { + return None; + } + let (mut l, mut t, mut r, mut b) = rect; + for edge in taskbars { + match edge { + Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?, + Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?, + Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?, + Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?, + _ => {} + } + } + let trimmed = (l, t, r, b); + (l < r && t < b && trimmed != rect).then_some(trimmed) + } + /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes /// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap /// keep working, as they did with winit 0.27's undecorated windows. @@ -162,7 +246,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_rect}; + use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge}; use winsafe::co; #[test] @@ -221,6 +305,35 @@ mod platform { assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); } + + #[test] + fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() { + // #4845: an auto-hidden taskbar reserves no work area. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (edge, expected) in + [(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))] + { + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected)); + assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed"); + } + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar"); + // A taskbar that reserves its work area is the clamp's job. + assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None); + assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen"); + } + + #[test] + fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown"); + assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden"); + assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top)); + assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left)); + assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right)); + assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor"); + assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow"); + } } } diff --git a/apps/photocraft/tests/it/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs new file mode 100644 index 0000000000..c4c745ea9a --- /dev/null +++ b/apps/photocraft/tests/it/command_line_usage.rs @@ -0,0 +1,95 @@ +//! `photocraft --help` and unknown options must not open the window (#6431): before the fix, +//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as +//! `--safegpu` launched the GUI and logged "Couldn't open --help". +//! +//! These run the real binary, and every case must exit before the window opens: Windows and +//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. +//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the +//! other usage errors, so stderr shows whether those arguments were taken as unknown options. + +/// Run `photocraft` with `args` in an isolated config dir; return the exit +/// status, stdout and stderr. +fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + .args(args) + .env("PHOTOCRAFT_CONFIG_DIR", &dir) + .env_remove("PHOTOCRAFT_CONTROL_PORT") + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .output() + .expect("failed to run photocraft binary"); + std::fs::remove_dir_all(&dir).ok(); + (output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned()) +} + +#[test] +fn help_prints_usage_and_exits_0() { + for flag in ["--help", "-h"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); + assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); + for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { + assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); + } + assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); + } +} + +#[test] +fn help_wins_over_later_arguments() { + let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); +} + +#[test] +fn unknown_options_exit_2_naming_them() { + for flag in ["--safegpu", "-x", "--help=yes"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); + assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); + assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + } +} + +#[test] +fn unknown_option_errors_are_reported_with_control_port_errors() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); +} + +#[test] +fn arguments_after_double_dash_are_files() { + // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage + // nor is an unknown option; only the bad port is reported. + let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn macos_process_serial_number_is_still_ignored() { + // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. + let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn new_instance_is_a_known_option() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} diff --git a/apps/photocraft/tests/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs similarity index 100% rename from apps/photocraft/tests/control_exit_code.rs rename to apps/photocraft/tests/it/control_exit_code.rs diff --git a/apps/photocraft/tests/it/main.rs b/apps/photocraft/tests/it/main.rs new file mode 100644 index 0000000000..b5ece12621 --- /dev/null +++ b/apps/photocraft/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod command_line_usage; +mod control_exit_code; +mod startup_without_display; diff --git a/apps/photocraft/tests/startup_without_display.rs b/apps/photocraft/tests/it/startup_without_display.rs similarity index 100% rename from apps/photocraft/tests/startup_without_display.rs rename to apps/photocraft/tests/it/startup_without_display.rs diff --git a/assets/icons/align-edge-bottom.svg b/assets/icons/align-edge-bottom.svg new file mode 100644 index 0000000000..e2993c8305 --- /dev/null +++ b/assets/icons/align-edge-bottom.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-hcenter.svg b/assets/icons/align-edge-hcenter.svg new file mode 100644 index 0000000000..264b4fe9bb --- /dev/null +++ b/assets/icons/align-edge-hcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-left.svg b/assets/icons/align-edge-left.svg new file mode 100644 index 0000000000..192310fd3f --- /dev/null +++ b/assets/icons/align-edge-left.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-right.svg b/assets/icons/align-edge-right.svg new file mode 100644 index 0000000000..0386cf7475 --- /dev/null +++ b/assets/icons/align-edge-right.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-top.svg b/assets/icons/align-edge-top.svg new file mode 100644 index 0000000000..54d4bdce21 --- /dev/null +++ b/assets/icons/align-edge-top.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-vcenter.svg b/assets/icons/align-edge-vcenter.svg new file mode 100644 index 0000000000..6df5d35096 --- /dev/null +++ b/assets/icons/align-edge-vcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-bottom.svg b/assets/icons/distribute-bottom.svg new file mode 100644 index 0000000000..20d3b31c33 --- /dev/null +++ b/assets/icons/distribute-bottom.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-hcenter.svg b/assets/icons/distribute-hcenter.svg new file mode 100644 index 0000000000..34faee88f4 --- /dev/null +++ b/assets/icons/distribute-hcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-horizontal.svg b/assets/icons/distribute-horizontal.svg new file mode 100644 index 0000000000..fef7592891 --- /dev/null +++ b/assets/icons/distribute-horizontal.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-left.svg b/assets/icons/distribute-left.svg new file mode 100644 index 0000000000..075305c0c0 --- /dev/null +++ b/assets/icons/distribute-left.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-right.svg b/assets/icons/distribute-right.svg new file mode 100644 index 0000000000..659d58fbee --- /dev/null +++ b/assets/icons/distribute-right.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-top.svg b/assets/icons/distribute-top.svg new file mode 100644 index 0000000000..1f02d74c9b --- /dev/null +++ b/assets/icons/distribute-top.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-vcenter.svg b/assets/icons/distribute-vcenter.svg new file mode 100644 index 0000000000..4d1734a083 --- /dev/null +++ b/assets/icons/distribute-vcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-vertical.svg b/assets/icons/distribute-vertical.svg new file mode 100644 index 0000000000..fdc7c6abd2 --- /dev/null +++ b/assets/icons/distribute-vertical.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/ellipse-dashed.svg b/assets/icons/ellipse-dashed.svg new file mode 100644 index 0000000000..f852dcf3a7 --- /dev/null +++ b/assets/icons/ellipse-dashed.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/quick-selection.svg b/assets/icons/quick-selection.svg index 454b38bae6..7e63dc66b9 100644 --- a/assets/icons/quick-selection.svg +++ b/assets/icons/quick-selection.svg @@ -9,8 +9,8 @@ stroke-linecap="round" stroke-linejoin="round" > - - - - + + + + diff --git a/assets/icons/slice-select.svg b/assets/icons/slice-select.svg new file mode 100644 index 0000000000..a32003ada0 --- /dev/null +++ b/assets/icons/slice-select.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/text-warp.svg b/assets/icons/text-warp.svg new file mode 100644 index 0000000000..03f30ed87b --- /dev/null +++ b/assets/icons/text-warp.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 4f3d02496a..2c36b464c2 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -5,6 +5,9 @@ - **Headless:** an in-process `Headless` session owns the engine and performs file I/O. - **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control `. +For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register). +The installed binary survives `cargo clean`; a symlink into `target/release` does not. + Start the headless server with: ```sh diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 39385877a1..40ecbdd127 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -8,8 +8,11 @@ tiles/.zst blobs/.zst thumb.png composite/preview.png +save.lock ``` +`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. + The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly. ## Implemented load limits diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 2e65d51550..590260962e 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,6 +19,8 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered. +- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..30077a230f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,13 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.brianblakely] +sync_github_name = true + +[people.abdul-karim-mia] +real_name = "Abdul karim mia" +sync_github_name = false + [people.jonathanwxh-cell] display_name = "jonathnwxh" sync_github_name = false @@ -116,6 +123,9 @@ sync_github_name = false [people.axlecoffee] sync_github_name = true +[people.zi-ger] +sync_github_name = true + [people.Losminek] sync_github_name = true @@ -159,6 +169,85 @@ sync_github_name = false real_name = "Ufuk Efe" sync_github_name = true +[people.nicolasfalesy] +sync_github_name = true + [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.theoabw] +real_name = "Theo Wilenius" + +[people.TaylorBurke] +sync_github_name = true + +[people.1Git2Clone] +sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.Byxis] +sync_github_name = true + +[people.00xJS] +sync_github_name = true + +[people.guillehr2] +real_name = "Guillem Hermida" +sync_github_name = true + +[people.fjamesprice] +sync_github_name = true + +[people.ha1des] +sync_github_name = true + +[people.webdevtodayjason] +display_name = "Jason Brashear" + +[people.AndreeSalazar] +real_name = "Andreé Salazar" +sync_github_name = true + +[people.LorenzoFasolino] +real_name = "Lorenzo Fasolino" +display_name = "Lorenzo Fasolino" +sync_github_name = false + +[people.zolyy] +real_name = "Gall Zoltan" +sync_github_name = false + +[people.theshubh77] +real_name = "Shubham Bhamare" +sync_github_name = true + +[people.ksaxena149] +sync_github_name = true + +[people.giang17] +real_name = "Giang Nguyen" +sync_github_name = true + +[people.aqibmohd271] +sync_github_name = true + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.Gotix-eg] +real_name = "Ahmed Tammam" +display_name = "Gotix" +sync_github_name = true + +[people.adi-1108] +real_name = "Aditya Vishwakarma" +sync_github_name = true + +[people.benandez] +real_name = "Martin Ball" diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 3e4e066156..1be7fd954e 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, checksums, budgets, cycles, hostile streams, every truncation) and property tests of random - mutations, here and in `photocraft-io`'s `tests/affinity.rs`. + mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`. * `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art) - (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs` - parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and + (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs` + parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a ceiling per file). * `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`) @@ -139,11 +139,18 @@ zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve -nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient +nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. +Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across +the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot +visits (including nulls and non-object values), and four million points created or copied into +compounds. Operand slots are charged before filtering, without first collecting an operand list. +A compound that crosses a limit or contains a cycle is omitted +with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform. + ## Privacy Affinity documents can hold the folder they were saved in, user names, original image paths and diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs index 0b60ed7dbd..732feeef49 100644 --- a/crates/affinity/src/access.rs +++ b/crates/affinity/src/access.rs @@ -20,6 +20,15 @@ impl Stream { self.class(id) == Some(Tag::of(class)) } + /// The raw slot count of an object's array field (including null slots), for charging + /// traversal work before nulls are filtered out. `None` when the field is not an array. + pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Array(a) => Some(a.len()), + _ => None, + } + } + /// A child object (shared, linked or inline); `None` for null or missing. pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option { match self.field(id, tag)? { diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 08554134db..6825abd511 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value}; /// Nodes per curve object. const MAX_NODES: usize = 4_000_000; +const MAX_COMPOUND_DEPTH: usize = 64; +const MAX_OUTLINE_VISITS: usize = 500_000; +const MAX_OUTLINE_POINTS: usize = 4_000_000; + +/// Geometry is expanded per occurrence, since parametric outlines can depend on their world +/// transform. Bound work across the entire document instead of memoizing by object id alone. +pub(crate) struct Budget { + visits_left: usize, + points_left: usize, +} + +impl Default for Budget { + fn default() -> Self { + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + } +} + +impl Budget { + /// Charge an outline call or a raw compound operand slot before inspecting it. + fn visit(&mut self) -> Result<(), &'static str> { + self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?; + Ok(()) + } + + /// Charge both leaf geometry and each copy into a compound before allocating that copy. + fn path(&mut self, path: &Path) -> Result<(), &'static str> { + let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); + let Some(left) = self.points_left.checked_sub(points) else { + // Do not rebuild an expensive leaf for every subsequent root after exhaustion. + self.visits_left = 0; + return Err("compound geometry budget exhausted"); + }; + self.points_left = left; + Ok(()) + } +} /// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. /// Instances (master-page or symbol copies) take the geometry of their master. pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { + match outline_inner(r, id, class, world, &mut Vec::new()) { + Ok(path) => path, + Err(reason) => { + r.warn(reason); + None + } + } +} + +fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec) -> Result, &'static str> { + r.geometry_budget.visit()?; + if active.contains(&id) { + return Err("cyclic compound shape"); + } + // Independent of the layer-tree limit: Comp operands do not pass through Reader::node. + if active.len() >= MAX_COMPOUND_DEPTH { + return Err("compound depth exceeds 64 levels"); + } + if class != Tag::of(b"Comp") { + let path = leaf_outline(r, id, class, world); + if let Some((path, _)) = &path { + r.geometry_budget.path(path)?; + } + return Ok(path); + } + active.push(id); + let result = compound(r, id, world, active); + active.pop(); + result +} + +fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec) -> Result, &'static str> { + let s = r.s; + // One path of all operands' contours, filled even-odd, as before. A safety failure rejects + // the whole outline: a partial add/subtract shape could silently change its meaning. + let mut path = Path::default(); + // Charge every slot before filtering nulls or malformed values. Stream::objs would scan + // and allocate the whole list first, repeating uncharged work for each shared occurrence. + let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] }; + for child in children { + r.geometry_budget.visit()?; + let Value::Obj(Some(c)) = child else { continue }; + let c = *c; + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let Some(class) = s.class(c) else { return Ok(None) }; + if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { + r.warn("compound shapes that intersect or exclude (imported as add)"); + } + if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? { + r.geometry_budget.path(&p)?; + path.subpaths.extend(p.transformed(local).subpaths); + } + } + Ok(Some((path, true))) +} + +fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { let s = r.s; match &class.0.to_be_bytes() { b"PCrv" | b"TxtC" => { @@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O let multi = path.subpaths.len() > 1; Some((path, multi)) } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - if let Some((p, _)) = outline(r, c, class, local.then(world)) { - path.subpaths.extend(p.transformed(local).subpaths); - } - } - Some((path, true)) - } _ => { let (shape, box_owner) = match s.obj(id, b"Shpe") { Some(sh) => (sh, id), @@ -134,7 +210,16 @@ fn anchors(records: &[Value]) -> Option> { fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { let first = anchors.first()?; if let Some(radii) = radii.filter(|r| !r.is_empty()) { - let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max); + // Index each record's maximum radius once. Scanning the whole radius list per anchor + // was O(anchors * radii), which a file with large anchor and radius arrays could drive + // into quadratic work. Duplicate record indices keep the maximum, matching the prior + // fold(0.0, f64::max) behavior. + let mut radius_by_record: std::collections::HashMap = std::collections::HashMap::with_capacity(radii.len()); + for &(rec, r) in radii { + let slot = radius_by_record.entry(rec).or_insert(0.0); + *slot = slot.max(r); + } + let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0); let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); if polyline { let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 7fba5a4597..f77c970ae2 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -283,6 +283,12 @@ const MAX_TREE_DEPTH: usize = 128; /// Total layer nodes imported. const MAX_NODES: usize = 500_000; +/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots +/// reached through repeated references to a shared group. The node limit only counts produced +/// nodes, so a shared group holding a large null-filled child array could still be rescanned +/// many times; this bounds that work. Generous relative to MAX_NODES for real documents. +const MAX_CHILD_SLOTS: usize = 8_000_000; + /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; @@ -297,7 +303,9 @@ pub(crate) struct Reader<'s, 'a, 'b> { pub(crate) dpi: f64, warnings: BTreeMap, nodes: usize, + child_slots: usize, active: HashSet, + pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so /// far hold). pixels_left: usize, @@ -305,7 +313,17 @@ pub(crate) struct Reader<'s, 'a, 'b> { impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + nodes: 0, + child_slots: 0, + active: HashSet::new(), + geometry_budget: crate::geometry::Budget::default(), + pixels_left: limits.max_pixel_bytes, + } } /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's @@ -373,6 +391,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; + // Charge every raw child slot (null slots included) against a document-wide budget + // before filtering. A shared group with a large null-filled Chld array could otherwise + // be rescanned through each reference without the node limit noticing. + self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0)); + if self.child_slots > MAX_CHILD_SLOTS { + return Err(Error::Limit("too many child slots in the layer tree")); + } for c in s.objs(parent, b"Chld") { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff8446..343c276b60 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option> { } } +/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB +/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared +/// objects, so one can link to itself, and with both links followed at every level a colour read +/// costs up to 2^(depth+1) lookups. +const MAX_SPOT_DEPTH: usize = 4; + /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_at(r, id, 0) +} + +/// [`color`] of an object `depth` spot colours down. +fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } } b"Pant" => { + if depth >= MAX_SPOT_DEPTH { + r.warn("spot colours nested deeper than 4 levels (left empty)"); + return None; + } r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index a755747c45..2ffe12b7e5 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -73,11 +73,40 @@ pub struct Object { pub chain: Vec, /// Fields in stream order; positional fields have tag 0. pub fields: Vec<(Tag, Value)>, + /// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object + /// can be referenced by many sibling layers, and each reference used to rescan all of its + /// fields; indexing once keeps repeated lookups from multiplying into quadratic work. + index: std::collections::HashMap, } impl Object { + pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self { + let mut index = std::collections::HashMap::with_capacity(fields.len()); + for (i, (t, _)) in fields.iter().enumerate() { + // Positional fields share tag 0 and are not looked up by tag; keep first-match + // semantics for real tags. + if t.0 != 0 { + index.entry(*t).or_insert(i); + } + } + Object { class, kind, chain, fields, index } + } + pub fn get(&self, tag: Tag) -> Option<&Value> { - self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v) + self.index.get(&tag).map(|&i| &self.fields[i].1) + } + + /// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the + /// tag index so `get` stays correct. + fn set_fields(&mut self, fields: Vec<(Tag, Value)>) { + self.index.clear(); + self.index.reserve(fields.len()); + for (i, (t, _)) in fields.iter().enumerate() { + if t.0 != 0 { + self.index.entry(*t).or_insert(i); + } + } + self.fields = fields; } } @@ -124,10 +153,10 @@ pub fn parse(bytes: &[u8]) -> Result { c.u32()?; } let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; - let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?; + let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?; let fields = p.fields(true, class)?; if let Some(o) = p.objects.get_mut(root) { - o.fields = fields; + o.set_fields(fields); } Ok(Stream { root, objects: p.objects }) } @@ -252,7 +281,7 @@ impl Parser<'_> { } 0x30 => { let fields = self.fields(false, class)?; - let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?; + let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?; Value::Obj(Some(id)) } 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), @@ -350,7 +379,7 @@ impl Parser<'_> { } }; let fields = self.fields(true, class)?; - let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?; + let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?; Ok(Value::Obj(Some(id))) } @@ -369,7 +398,7 @@ impl Parser<'_> { return Err(Error::Malformed("object defined twice")); } // Register before the fields: an object's own fields may refer back to it. - let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?; + let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?; self.shared.insert(key, id); let mut chain = Vec::new(); let mut fields = Vec::new(); @@ -395,7 +424,7 @@ impl Parser<'_> { let class = chain.first().copied().unwrap_or(Tag(0)); fields.extend(self.fields(true, class)?); if let Some(o) = self.objects.get_mut(id) { - *o = Object { class, kind: Kind::Shared, chain, fields }; + *o = Object::new(class, Kind::Shared, chain, fields); } Ok(Value::Obj(Some(id))) } diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 59ae99044a..a5517653de 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -1,4 +1,4 @@ -//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents. +//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. mod preview { use crate::container::header; @@ -538,3 +538,174 @@ mod pixels { ); } } + +/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB +/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process +/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded +/// recursion fails the one test that caused it instead of taking the other tests in the binary +/// down with it. +mod spot_colours { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::paint::{Color, Paint}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + /// A one-page document whose page holds `top`, read in a child process first and then here. + fn read(top: Vec) -> Document { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let test = std::thread::current().name().unwrap_or("reader").to_owned(); + read_in_child(&bytes, &test); + read_on_small_stack(bytes, &test).unwrap() + } + + /// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test. + fn read_on_small_stack(bytes: Vec, test: &str) -> Result { + let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap(); + reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + } + + /// Set in the child process to the name of the test it reads for. + const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD"; + + /// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if + /// that process crashes, panics or takes over 20 s, or never read the document. + fn read_in_child(bytes: &[u8], test: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .env(CHILD, test) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let drain = |mut pipe: Box| { + std::thread::spawn(move || { + let mut text = String::new(); + let _ = pipe.read_to_string(&mut text); + text + }) + }; + let stdout = drain(Box::new(child.stdout.take().unwrap())); + let stderr = drain(Box::new(child.stderr.take().unwrap())); + // Fed from a thread, so a child that never reads cannot block the deadline below; a child + // that dies first breaks the pipe and is reported by its exit status. + let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec()); + let feed = std::thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + feed.join().unwrap(); + let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap()); + match status { + None => panic!("reading the document did not finish within 20 s:\n{output}"), + Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"), + // A filter that matched no test would also exit successfully, having read nothing. + Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"), + } + } + + /// Not a test of its own: `read_in_child` runs it in a child process to read the document on + /// its stdin. Without the variable, as when run with `--ignored`, it does nothing. + #[test] + #[ignore = "run by read_in_child in a child process"] + fn read_child() { + let Some(test) = std::env::var_os(CHILD) else { return }; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + let len = bytes.len(); + let _ = read_on_small_stack(bytes, &test.to_string_lossy()); + println!("read {len} bytes"); + } + + /// An ellipse filled with `colour`. + fn filled(id: u32, colour: F) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])), + (tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])), + (tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])), + ], + ) + } + + fn spot(id: u32, fields: Vec<(Tag, F)>) -> F { + F::Def(id, vec![tag(b"Pant")], fields) + } + + fn fills(doc: &Document) -> &[Paint] { + match &doc.spreads[0].nodes[0].kind { + Kind::Shape { fills, .. } => fills, + other => panic!("expected a shape, got {other:?}"), + } + } + + fn warned(doc: &Document, what: &str) -> bool { + doc.warnings.iter().any(|w| w.contains(what)) + } + + #[test] + fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_of_spot_colours_do_not_take_exponential_time() { + // Each level's RGB equivalent and base are the next level, and the last is in an unknown + // model, so both are followed everywhere: 2^41 colour reads before the fix. + fn level(id: u32, left: u32) -> F { + if left == 0 { + return F::Def(id, vec![tag(b"XXXX")], vec![]); + } + spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))]) + } + let doc = read(vec![filled(10, level(100, 40))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + } + + /// `spots` spot colours, each defined through the next, around an opaque RGB colour. + fn spots(id: u32, spots: u32) -> F { + if spots == 0 { + let rgba: Vec = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect(); + return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]); + } + spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))]) + } + + #[test] + fn spot_colours_nest_four_levels_and_no_deeper() { + let doc = read(vec![filled(10, spots(100, 4))]); + assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]); + let doc = read(vec![filled(10, spots(100, 5))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } +} diff --git a/crates/affinity/tests/it/compound_limits.rs b/crates/affinity/tests/it/compound_limits.rs new file mode 100644 index 0000000000..6801f7f6d6 --- /dev/null +++ b/crates/affinity/tests/it/compound_limits.rs @@ -0,0 +1,214 @@ +//! #6423: compound operands form a graph, not necessarily a tree. +#![cfg(feature = "synth")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use photocraft_affinity::model::{Document, Kind}; +use photocraft_affinity::synth::{self, F, Method, tag}; +use photocraft_affinity::{Limits, read}; +use std::time::{Duration, Instant}; + +fn compound(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +fn rectangle() -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))]) +} + +fn document_bytes(definitions: Vec, roots: Vec) -> Vec { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))], + )]), + )], + ), + ), + ]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) +} + +fn document(definitions: Vec, roots: Vec) -> Document { + read(&document_bytes(definitions, roots), Limits::default()).unwrap() +} + +// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang) +// inside a child process, so the regression fails without killing the entire test runner. +fn isolated(name: &str, check: impl FnOnce()) { + if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) { + check(); + return; + } + // The harness names a test by its module path below the crate root. + let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}")); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test, "--nocapture"]) + .env("PHOTOCRAFT_COMPOUND_CASE", name) + .spawn() + .unwrap(); + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "compound import failed: {status}"); + break; + } + if start.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("compound import exceeded ten seconds"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn unsupported(doc: &Document, warning: &str) { + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings); +} + +#[test] +fn self_referencing_compound_is_reported_without_aborting() { + isolated("self_referencing_compound_is_reported_without_aborting", || { + let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]); + unsupported(&doc, "cyclic compound"); + assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes"); + }); +} + +#[test] +fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() { + isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || { + let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]); + unsupported(&doc, "cyclic compound"); + }); +} + +fn nested(levels: u32, repeated: bool) -> Document { + // Flat definitions bypass the stream parser's nesting cap: only references form the depth. + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..200 + levels { + let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] }; + definitions.push(compound(id, children)); + previous = id; + } + document(definitions, vec![F::Ref(previous)]) +} + +#[test] +fn deep_compound_graph_is_bounded_on_a_small_stack() { + isolated("deep_compound_graph_is_bounded_on_a_small_stack", || { + std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap(); + }); +} + +#[test] +fn shared_operands_cannot_expand_exponentially() { + isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget")); +} + +#[test] +fn shared_empty_operands_also_have_a_work_limit() { + isolated("shared_empty_operands_also_have_a_work_limit", || { + let mut definitions = vec![compound(100, vec![])]; + let mut previous = 100; + for id in 200..240 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget"); + }); +} + +#[test] +fn geometry_budget_is_shared_across_layer_roots() { + isolated("geometry_budget_is_shared_across_layer_roots", || { + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..214 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + let mut roots = vec![F::Ref(previous); 12]; + roots.push(F::Ref(100)); + let doc = document(definitions, roots); + let nodes = &doc.spreads[0].nodes; + assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports"); + assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget"); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn ordinary_shared_operands_keep_both_transformed_copies() { + let mut translated = compound(201, vec![F::Ref(100)]); + let F::Def(_, _, fields) = &mut translated else { unreachable!() }; + fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0]))); + let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) { + assert_eq!(b.x, a.x + 30.0); + assert_eq!(b.y, a.y + 40.0); + } +} + +#[test] +fn null_operand_slots_cannot_bypass_geometry_budget() { + isolated("null_operand_slots_cannot_bypass_geometry_budget", || { + let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]); + let start = Instant::now(); + let doc = read(&bytes, Limits::default()).unwrap(); + eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64()); + unsupported(&doc, "compound geometry budget"); + assert_eq!(doc.spreads[0].nodes.len(), 20_000); + assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported))); + }); +} + +#[test] +fn null_operand_slots_share_budget_across_layer_roots() { + isolated("null_operand_slots_share_budget_across_layer_roots", || { + let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid"); + assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn null_operand_slots_cannot_leave_a_partial_shape() { + isolated("null_operand_slots_cannot_leave_a_partial_shape", || { + let mut slots = vec![F::Ref(100)]; + slots.extend(vec![F::Null; 1_000_000]); + let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]); + unsupported(&doc, "compound geometry budget"); + }); +} + +#[test] +fn non_object_operand_slots_also_consume_budget() { + isolated("non_object_operand_slots_also_consume_budget", || { + let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]); + unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget"); + }); +} + +#[test] +fn ordinary_null_operand_slots_preserve_shared_contours() { + let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], path.subpaths[1]); +} diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs new file mode 100644 index 0000000000..5d0a5740b8 --- /dev/null +++ b/crates/affinity/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod compound_limits; +mod real_files; diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs similarity index 100% rename from crates/affinity/tests/real_files.rs rename to crates/affinity/tests/it/real_files.rs diff --git a/crates/algo/examples/bench_filters2.rs b/crates/algo/examples/bench_filters2.rs index a84bb3ca59..9849c4f843 100644 --- a/crates/algo/examples/bench_filters2.rs +++ b/crates/algo/examples/bench_filters2.rs @@ -33,6 +33,8 @@ fn main() { depth_map: None, }, FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal }, + FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal }, + FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal }, FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star }, FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 }, FilterParams::IrisBlur { pins: vec![IrisPin::default()] }, @@ -60,7 +62,12 @@ fn main() { let area = output_area(&p, s.content_bounds(), r, None).intersect(&r); let t = std::time::Instant::now(); let out = apply(&s, &p, area, r, None); - println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed()); + // Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle. + let name = match p { + FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()), + _ => p.label().to_string(), + }; + println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed()); drop(out); } } diff --git a/crates/algo/examples/bench_liquify.rs b/crates/algo/examples/bench_liquify.rs index 2eea3b951b..78165171f2 100644 --- a/crates/algo/examples/bench_liquify.rs +++ b/crates/algo/examples/bench_liquify.rs @@ -127,10 +127,23 @@ fn main() { photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3 ); - let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); - let t = Instant::now(); - let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v)); - println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3); + // Compare the old path-dependent preview with the command-equivalent drag solve, using + // changing destinations and the same cached factorization (no remeshing per event). + for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] { + let mut previous = v.clone(); + let mut times = Vec::new(); + for step in 1..=60 { + let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); + dst[1][0] -= f64::from(step) * 10.0; + dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0; + let t = Instant::now(); + let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice())); + times.push(t.elapsed().as_secs_f64() * 1e3); + previous = std::hint::black_box(next); + } + times.sort_by(f64::total_cmp); + println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]); + } let t = Instant::now(); let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic); println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3); diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs new file mode 100644 index 0000000000..9d58b81a54 --- /dev/null +++ b/crates/algo/examples/bench_multiband.rs @@ -0,0 +1,42 @@ +//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels +//! (3:2), each overlapping the next by half its width. +//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). +//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +use std::time::Instant; + +use photocraft_algo::panorama::{self, RoiImage}; + +/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. +fn peak_mib() -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb / 1024.0) +} + +fn main() { + let mut args = std::env::args().skip(1); + let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); + let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); + let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); + let sw = sh * 3 / 2; + let step = sw / 2; + let (cw, ch) = (step * (n - 1) + sw, sh); + let rois: Vec = (0..n) + .map(|k| { + let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); + RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } + }) + .collect(); + // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. + let wts: Vec> = (0..n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + .collect(); + let levels = panorama::blend_levels(cw, ch).min(7); + let before = peak_mib(); + let t0 = Instant::now(); + let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); + println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); + std::hint::black_box((px, cov)); +} diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index 4d2e85ed1a..1cca6c9468 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2, F::ConteCrayon => 0, F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2, - F::HalftonePattern => gs(0.5 + g("size") * 0.3), + F::HalftonePattern => gs(halftone_cell(e) * 0.15), F::NotePaper => gs(1.0) + gs(1.2) + 2, F::Photocopy => gs(1.0 + g("detail") * 0.5), F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2, @@ -454,6 +454,55 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { fn cutout_sigma(e: &GalleryEffect) -> f32 { 0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity")) } +/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12). +fn halftone_cell(e: &GalleryEffect) -> f32 { + 1.5 + 2.0 * e.get("size").clamp(1.0, 12.0) +} +/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a +/// value's rank among them is the share of the cell below it, which makes the thresholds even. +fn halftone_dot_ranks() -> &'static [f32] { + static RANKS: std::sync::OnceLock> = std::sync::OnceLock::new(); + RANKS.get_or_init(|| { + const N: usize = 128; + let mut v: Vec = (0..N * N) + .map(|i| { + let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32); + ((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0 + }) + .collect(); + v.sort_by(f32::total_cmp); + v + }) +} +/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft +/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that +/// share of a cell on average. +fn halftone_offsets(gain: f32) -> Vec { + let half = 0.5 / gain.max(1e-3); + // Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half. + let cover = |tau: f32| { + let (a, z) = (tau - half, tau + half); + let solid = a.clamp(0.0, 1.0); + let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0)); + let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 }; + solid + ramp + }; + (0..256) + .map(|i| { + let target = i as f32 / 255.0; + let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0); + for _ in 0..40 { + let mid = (lo + hi) / 2.0; + if cover(mid) < target { lo = mid } else { hi = mid } + } + match i { + 0 => -half - 1.0, + 255 => 1.0 + half + 1.0, + _ => (lo + hi) / 2.0, + } + }) + .collect() +} fn daubs_radius(e: &GalleryEffect) -> usize { let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry"); let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 }; @@ -506,6 +555,7 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx) res } +/// Applies one gallery effect to the working window in place. fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { let g = |k: &str| e.get(k); let (w, h) = (win.w, win.h); @@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) { } } F::HalftonePattern => { - let size = g("size"); - let con = g("contrast") / 50.0; - let cell = 3.0 + size * 2.0; + // A halftone screen that keeps a continuous range of tones: each pixel compares its + // ink (the darkness of the image) with the screen's threshold there, and Contrast is + // how steeply: at 0 the image keeps its tones with the screen faintly through it, at + // 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell, + // and each tone is offset so a cell's average ink is exactly that tone: black stays + // solid and white stays paper at any Contrast. + let cell = halftone_cell(e); + let gain = 0.5 + g("contrast") * 0.6; + let offsets = halftone_offsets(gain); let mut l = win.lum(); - blur1(&mut l, w, h, 0.5 + size * 0.3); + blur1(&mut l, w, h, cell * 0.15); let b = ctx.bounds; let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0); let ty = e.choice("patternType"); + let dots = halftone_dot_ranks(); for i in 0..win.c.len() { let (x, y) = win.xy(i); let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); - let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5); - let aa = 1.0 / cell; - let ink = match ty { - "circle" => { - let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell; - let q = (r.fract() - 0.5).abs() * 2.0; - smoothstep(d + aa, d - aa, q) - } - "line" => { - let q = ((fy / cell).fract() - 0.5).abs() * 2.0; - smoothstep(d + aa, d - aa, q) - } + let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0; + let spot = match ty { + // Rings round the middle of the image, inked from the middle of each ring. + "circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()), + // Horizontal lines, inked from the middle of each. + "line" => band(fy), + // Round dots on a screen at 45° (as a black halftone screen is), that grow + // from each cell's centre, meet at 50% as a checkerboard of squares square to + // the image, and fill the cell at 100%. _ => { - let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5); - let q = (u * u + v * v).sqrt() * 2.0; - let rr = d.sqrt() * 1.15; - smoothstep(rr + aa, rr - aa, q) + let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2); + let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0)); + let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0; + let k = dots.partition_point(|&r| r < f); + k as f32 / dots.len().max(1) as f32 } }; + let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32; + let (k, f) = (tone.floor() as usize, tone.fract()); + let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5); + let level = at(k) + (at(k + 1) - at(k)) * f; + let ink = clamp01(0.5 + (level - spot) * gain); win.c[i] = mix(bg, fg, ink); } } @@ -1254,6 +1314,43 @@ mod tests { } } + /// Flat greys keep their tone through every pattern and Contrast, black and white stay + /// solid, and Contrast makes the screen crisper. + #[test] + fn halftone_keeps_tones_and_contrast_sets_how_hard() { + // It used to be all ink or paper at any Contrast, with white crosses in the shadows + // (dots could never fill their cell) and mid-greys that came out the wrong darkness. + use crate::GalleryFilter as G; + let b = Rect::new(0, 0, 60, 60); + let run = |grey: f32, pattern: &str, contrast: f32| { + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + s.fill_rect(b, &[grey, grey, grey, 1.0]); + let mut e = GalleryEffect::new(G::HalftonePattern); + assert!(e.set_choice("patternType", pattern)); + e.set("size", 4.0); + e.set("contrast", contrast); + let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b); + // Ink (1 = foreground black) of every pixel away from the canvas edge. + out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::>() + }; + for pattern in ["dot", "circle", "line"] { + for contrast in [0.0, 5.0, 50.0] { + for grey in [0.2, 0.5, 0.8] { + let ink = run(grey, pattern, contrast); + let mean = ink.iter().sum::() / ink.len() as f32; + assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}"); + } + let solid = run(0.0, pattern, contrast); + assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid"); + let paper = run(1.0, pattern, contrast); + assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper"); + } + let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count(); + let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count(); + assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)"); + } + } + #[test] fn params_parse_and_clamp() { for f in crate::GalleryFilter::ALL { @@ -1305,7 +1402,7 @@ mod tests { } /// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored` - /// writes `.raw` (RGB8) beside the input for every filter. + /// writes `.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`). #[test] #[ignore] fn gallery_sheet() { @@ -1318,11 +1415,29 @@ mod tests { let b = Rect::new(0, 0, w, h); s.write_region(b, &data); let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf(); - for f in crate::GalleryFilter::ALL { - let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] }; + // Optional: GALLERY_ONLY= renders one filter; GALLERY_SET="key=value,key=name" sets its + // sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name. + let only = std::env::var("GALLERY_ONLY").ok(); + let set = std::env::var("GALLERY_SET").unwrap_or_default(); + let tag = std::env::var("GALLERY_TAG").unwrap_or_default(); + if let Some(k) = &only { + assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter"); + } + for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) { + let mut e = GalleryEffect::new(*f); + for kv in set.split(',').filter(|kv| !kv.is_empty()) { + let (k, v) = kv.split_once('=').unwrap(); + // `set` ignores unknown keys; a misspelt one would render the default unnoticed. + assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key()); + match v.parse::() { + Ok(n) => e.set(k, n), + Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"), + } + } + let fp = FilterParams::FilterGallery { effects: vec![e] }; let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b); let raw: Vec = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect(); - std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap(); + std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap(); } } } diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index ae8b12f40a..fb62681222 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -212,12 +212,17 @@ fn box_pass_px(src: &[f32], dst: &mut [f32], r: usize) { } /// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many -/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. -fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) { +/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving +/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of +/// milliseconds on a tall image, too long to finish once cancelled (#6205). +fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) { let Scratch { line, next, acc } = s; let Some(len) = dst.len().checked_div(w) else { return }; let mut tail: usize = radii.iter().sum(); for (k, &r) in radii.iter().enumerate() { + if ctl.cancelled() { + return; + } tail -= r; if k + 1 == radii.len() { box_pass(line, dst, w, r, acc); @@ -281,7 +286,7 @@ pub(crate) fn box_blur( (0..left).for_each(|_| s.line.extend_from_slice(first)); s.line.extend_from_slice(&row); (0..right).for_each(|_| s.line.extend_from_slice(last)); - line_passes(dst, n, &radii, &mut s); + line_passes(dst, n, &radii, &mut s, ctl); } }); // Vertical passes, strip by strip, the same way down the columns. @@ -300,7 +305,10 @@ pub(crate) fn box_blur( (0..rows).for_each(|y| s.line.extend_from_slice(row(y))); (0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1))); let mut res = vec![0.0f32; oh * w]; - line_passes(&mut res, w, &radii, &mut s); + line_passes(&mut res, w, &radii, &mut s, ctl); + if ctl.cancelled() { + return (t, Vec::new()); + } unpremultiply(&mut res, n, alpha); (t, res) }); @@ -1180,4 +1188,61 @@ mod tests { } } } + + /// A column strip's passes stop between passes once cancelled, not only before the strip + /// (#6205); uncancelled, every pass runs. + #[test] + fn line_passes_stop_between_passes_when_cancelled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize); + let reach: usize = radii.iter().sum(); + let line: Vec = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect(); + let run = |ctl: &Interrupt| { + let mut s = Scratch { line: line.clone(), ..Scratch::default() }; + let mut dst = vec![0.0f32; len * w]; + line_passes(&mut dst, w, &radii, &mut s, ctl); + dst + }; + assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0)); + let calls = AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1; + // Cancelled after the first pass: the last pass (the one writing `dst`) never runs. + assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + /// A box blur cancelled at any of its checks returns `None` (never a half-blurred result); + /// one that is asked but never cancelled is bit-identical to the uninterruptible run. + #[test] + fn box_blur_honours_a_cancel_at_every_check() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (n, src) = (4usize, Rect::new(0, 0, 150, 90)); + let px: Vec = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect(); + let read = |r: Rect, buf: &mut Vec| { + buf.clear(); + let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize); + let row = r.y0 as usize * src.width() as usize * n; + buf.extend_from_slice(&px[row + x0..row + x1]); + }; + let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") }; + let out = Rect::new(-5, 3, 155, 80); + let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap(); + let checks = AtomicUsize::new(0); + let count = || { + checks.fetch_add(1, Ordering::SeqCst); + false + }; + let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap(); + assert_eq!(got.len(), want.len()); + for ((ta, a), (tb, b)) in got.iter().zip(&want) { + assert_eq!(ta, tb); + assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs"); + } + let total = checks.load(Ordering::SeqCst); + for k in (0..total).step_by((total / 40).max(1)) { + let seen = AtomicUsize::new(0); + let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k; + assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}"); + } + } } diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs index e917405083..dc57f39adb 100644 --- a/crates/algo/src/blur2.rs +++ b/crates/algo/src/blur2.rs @@ -189,6 +189,11 @@ mod shape_tests; /// Smart Blur: averages only neighbours within `threshold` levels of the /// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the /// edges (where neighbours differ by more than the threshold) in white. +/// +/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole +/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at +/// precomputed offsets into the window instead of by coordinates, and for up to five +/// channels go through [`smart_rows`] (#7921). pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec { let n = src.ch; let cc = ncol(ctx, n); @@ -201,31 +206,48 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho BlurQuality::High => 12, }; let stride = ((ri + target - 1) / target).max(1); - let offs: Vec<(i32, i32)> = (-ri..=ri) + let win = src.rect; + let ww = win.width() as isize; + // Each offset with its distance in samples within the window. + let offs: Vec<(i32, i32, isize)> = (-ri..=ri) .step_by(stride as usize) .flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy))) .filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5) + .map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize)) .collect(); + let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n); + let zeros = [0.0f32; MAXC]; + let data = &src.data[..]; + // The pixel at sample `i`, or zeros outside the window. + let sample = |i: Option| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]); let white = native(ctx, [1.0, 1.0, 1.0, 1.0]); let black = native(ctx, [0.0, 0.0, 0.0, 1.0]); let mut res = src.crop(out); - let mut q = [0.0f32; MAXC]; + let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out); + let lin: Vec = offs.iter().map(|o| o.2).collect(); + let rows = match (mode, n, ctx.alpha) { + (SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res), + (SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res), + _ => Rect::EMPTY, + }; let mut acc = [0.0f32; MAXC]; for (i, px) in res.chunks_exact_mut(n).enumerate() { let (x, y) = xy(out, i); + if rows.contains(x, y) { + continue; + } let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 }); - let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t); + let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t); if mode != SmartBlurMode::Normal { - let mut edge = false; - for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] { - for (c, v) in q.iter_mut().enumerate().take(n) { - *v = src.get(x + dx, y + dy, c); - } - if differs(&q[..n]) { - edge = true; - break; - } - } + let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy)))); let a = if ctx.alpha { px[n - 1] } else { 1.0 }; if edge { px[..cc].copy_from_slice(&white[..cc]); @@ -237,18 +259,28 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho } continue; } + let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1; + let centre = if interior { at(x, y) } else { None }; acc[..n].fill(0.0); let mut wsum = 0.0f32; - for &(dx, dy) in &offs { - for (c, v) in q.iter_mut().enumerate().take(n) { - *v = src.get(x + dx, y + dy, c); - } - if differs(&q[..n]) { + for &(dx, dy, d) in &offs { + let q = sample(match centre { + Some(c) => c.checked_add_signed(d), + None => at(x + dx, y + dy), + }); + if differs(q) { continue; } - let a = if ctx.alpha { q[n - 1] } else { 1.0 }; - for c in 0..n { - acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] }; + if ctx.alpha { + let a = q[n - 1]; + for (s, v) in acc.iter_mut().zip(&q[..cc]) { + *s += v * a; + } + acc[n - 1] += a; + } else { + for (s, v) in acc.iter_mut().zip(q) { + *s += v; + } } wsum += 1.0; } @@ -262,6 +294,77 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho res } +/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N` +/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window. +/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds +/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same +/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the +/// rows finished, empty if the layout doesn't match. +fn smart_rows(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect { + let win = src.rect; + if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) { + return Rect::EMPTY; + } + let cc = if A { N - 1 } else { N }; + let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize); + let pixels = len * N; + let mut acc = vec![[0.0f32; N]; len]; + let mut wsum = vec![0.0f32; len]; + // Rows before `y` are done; a row is written only once all its neighbours were read. + let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y); + for y in inner.y0..inner.y1 { + let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N; + let Some(me) = src.data.get(centre..centre + pixels) else { + return done(y); + }; + let me = me.as_chunks::().0; + acc.fill([0.0; N]); + wsum.fill(0.0); + for &d in lin { + let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else { + return done(y); + }; + for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::().0).zip(me) { + // As `differs`: a NaN difference is not over the threshold. + let mut far = false; + for c in 0..cc { + far |= (q[c] - m[c]).abs() > t; + } + let keep = !far; + if A { + let a = q[N - 1]; + for c in 0..N - 1 { + s[c] += if keep { q[c] * a } else { -0.0 }; + } + s[N - 1] += if keep { a } else { -0.0 }; + } else { + for c in 0..N { + s[c] += if keep { q[c] } else { -0.0 }; + } + } + *w += if keep { 1.0 } else { 0.0 }; + } + } + let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N; + let Some(dst) = res.get_mut(o..o + pixels) else { + return done(y); + }; + for ((px, s), &w) in dst.as_chunks_mut::().0.iter_mut().zip(&acc).zip(&wsum) { + if w > 0.0 { + for c in 0..N { + px[c] = s[c] / w; + } + unpremul_px(px, A); + } + } + } + inner +} + +#[cfg(test)] +#[path = "smart_blur_tests.rs"] +mod smart_tests; + // ---------- lens blur ---------- pub(crate) struct LensSpec<'a> { @@ -335,7 +438,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V } src.get(x, y, n - 1) } - DepthSource::LayerMask => spec.depth_map?.get(x, y, 0), + DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0), }; Some(if spec.invert { 1.0 - d } else { d }) }; diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index f855b08bfe..3cadd1d20c 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -67,22 +67,16 @@ impl Encoding { pub(crate) fn of(gamma: f32) -> Self { Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) } + /// A value in this encoding as linear light. pub(crate) fn decode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } } + /// Linear light back into this encoding, the inverse of [`Encoding::decode`]. pub(crate) fn encode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } } } -fn srgb_encode(v: f32) -> f32 { - linear_to_srgb(v) -} - -fn srgb_decode(v: f32) -> f32 { - srgb_to_linear(v) -} - fn luma_linear(c: [f32; 3]) -> f32 { luma(c) } diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index e1305c5d49..1dbf58421f 100644 --- a/crates/algo/src/camera_raw/local_tone.rs +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane { Plane { w, h, v: out } } +/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the +/// zero-stuffed row is all zeros). +fn up_rows(p: &Plane, h: usize, y: usize) -> [Option; 5] { + let lh = p.h.min(h.div_ceil(2)); + std::array::from_fn(|k| { + let m = mirror(y as isize + k as isize - 2, h); + (m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2) + }) +} + +/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`. +/// It adds the same terms in the same order as `up`, so it gives the same bits. +fn up_at(p: &Plane, w: usize, rows: &[Option; 5], x: usize) -> f32 { + let lw = p.w.min(w.div_ceil(2)); + let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() }; + if x >= 2 && x + 2 < w { + // Away from the edges no tap mirrors. + (0..5) + .filter_map(|k| { + let i = x + k - 2; + (i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2)) + }) + .sum() + } else { + (0..5) + .filter_map(|k| { + let m = mirror(x as isize + k as isize - 2, w); + (m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2)) + }) + .sum() + } +} + /// Levels down to about 2 pixels on the short side. fn depth(w: usize, h: usize) -> usize { let mut s = w.min(h); @@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { }); let gr = gaussian(m, n); for l in 0..n - 1 { - let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); - let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); + let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]); rows(&mut lap[l], lw, |y, row| { + let taps = up_rows(coarse, lh, y); for (x, o) in row.iter_mut().enumerate() { let i = y * lw + x; let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); if wgt > 0.0 { - *o += wgt * (fine[i] - coarse.v[i]); + *o += wgt * (fine[i] - up_at(coarse, lw, &taps, x)); } } }); @@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { /// The `q` quantile (0 … 1) of `v`. fn quantile(v: &[f32], q: f32) -> f32 { let mut s = v.to_vec(); - s.sort_by(f32::total_cmp); - s[((s.len() - 1) as f32 * q).round() as usize] + // Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a + // fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element. + let i = ((s.len() - 1) as f32 * q).round() as usize; + *s.select_nth_unstable_by(i, f32::total_cmp).1 } /// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. @@ -444,11 +479,33 @@ mod tests { assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}"); } for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] { - assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + let full = up(&p, uw, uh); + assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + // The filter samples `up` one pixel at a time; every sample has the same bits. + for y in 0..uh { + let taps = up_rows(&p, uh, y); + for x in 0..uw { + assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}"); + } + } } } } + #[test] + fn quantile_is_the_sorted_element() { + let p = noise_plane(301, 211, 9); + let mut v = p.v.clone(); + // Ties and signed zeros, which `total_cmp` orders by their bits. + v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]); + let mut sorted = v.clone(); + sorted.sort_by(f32::total_cmp); + for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] { + let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize]; + assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}"); + } + } + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() } diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 382ade50e9..93bb9cff31 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco if per_channel { for c in 0..3 { let over = (lin[c] - 1.0).max(0.0); - q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over); } } else { let y = super::luma_linear(lin).max(0.0); if y <= 1e-7 { continue; } - let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0); let k = y2 / y; for c in 0..3 { q[c] = enc.encode(lin[c] * k); diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 76880cd5ad..981e854cc5 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3 .collect() } +/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of +/// diameter `size` along `points` (document px), adding to the sampling area or taking from it. +#[derive(Clone, Debug, PartialEq)] +pub struct SamplingStroke { + pub add: bool, + pub size: f64, + pub points: Vec<[f64; 2]>, +} + +impl SamplingStroke { + /// Bounds of the pixels the stroke covers. + pub fn bounds(&self) -> Rect { + let r = (self.size / 2.0).max(0.5); + self.points.iter().fold(Rect::EMPTY, |acc, p| { + let b = Rect::new( + (p[0] - r).floor() as i32, + (p[1] - r).floor() as i32, + ((p[0] + r).ceil() as i32).saturating_add(1), + ((p[1] + r).ceil() as i32).saturating_add(1), + ); + if acc.is_empty() { b } else { acc.union(&b) } + }) + } +} + +/// Squared distance from `p` to the segment `a`–`b`. +fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]); + qx * qx + qy * qy +} + +/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within +/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that +/// does not cover `area` exactly is left as it is. +pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) { + let w = area.width() as usize; + if mask.len() != w * area.height() as usize { + return; + } + for s in strokes { + let r = (s.size / 2.0).max(0.5); + let r2 = r.powi(2); + let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() { + [] => continue, + [p] => vec![(*p, *p)], + pts => pts.windows(2).map(|p| (p[0], p[1])).collect(), + }; + // A stroke covers the union of its segments' pixels: each segment only looks at its own + // bounds. + for (a, b) in segs { + let bounds = Rect::new( + (a[0].min(b[0]) - r).floor() as i32, + (a[1].min(b[1]) - r).floor() as i32, + ((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1), + ((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1), + ) + .intersect(&area); + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + let c = [f64::from(x) + 0.5, f64::from(y) + 0.5]; + if seg_dist2(c, a, b) <= r2 + && let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize) + { + *m = s.add; + } + } + } + } + } +} + +/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule): +/// what a lasso outlines. +pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut out = vec![false; w * h]; + if pts.len() < 3 { + return out; + } + let mut xs: Vec = Vec::new(); + for row in 0..h { + let cy = f64::from(area.y0) + row as f64 + 0.5; + xs.clear(); + for i in 0..pts.len() { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + if (a[1] <= cy) != (b[1] <= cy) { + xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0])); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.as_chunks::<2>().0 { + // Pixel centres x + 0.5 within [pair0, pair1). + let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true); + } + } + out +} + +/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand +/// and Contract. +pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec { + if by == 0 || w == 0 || h == 0 { + return mask.to_vec(); + } + let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX)); + if by > 0 { + let d = crate::selection::edt(mask, w, h); + d.iter().map(|v| *v <= r).collect() + } else { + let outside: Vec = mask.iter().map(|v| !v).collect(); + let d = crate::selection::edt(&outside, w, h); + d.iter().map(|v| *v > r).collect() + } +} + +/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling +/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100). +pub fn default_brush_size(hole: Rect) -> f64 { + let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50))); + (0.4 * (w * h).sqrt()).round() +} + #[cfg(test)] mod tests { use super::*; @@ -577,6 +704,43 @@ mod tests { (img, hole) } + #[test] + fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() { + let area = Rect::new(10, 20, 50, 40); + let (w, h) = (40, 20); + let strokes = [ + SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] }, + SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] }, + SamplingStroke { add: true, size: 2.0, points: Vec::new() }, + ]; + let mut mask = vec![false; w * h]; + paint_strokes(&mut mask, area, &strokes); + // The same pixels as testing every pixel against every segment of each stroke in turn. + let mut want = vec![false; w * h]; + for s in &strokes { + let r2 = (s.size / 2.0).max(0.5).powi(2); + let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() }; + for (i, m) in want.iter_mut().enumerate() { + let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5]; + if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) { + *m = s.add; + } + } + } + assert_eq!(mask, want); + assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab"); + // A mask shorter or longer than the area is not touched (and nothing panics). + for len in [0, 7, w * h - 1, w * h + 3] { + let mut short = vec![false; len]; + paint_strokes(&mut short, area, &strokes); + assert!(short.iter().all(|v| !v), "len {len}"); + } + // Hostile coordinates stay inside the area. + let mut m = vec![false; w * h]; + let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }]; + paint_strokes(&mut m, area, &wild); + } + #[test] fn cancelled_fill_stops_and_progress_reaches_one() { use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/algo/src/distort.rs b/crates/algo/src/distort.rs index 664169afa4..fb6e3e69c3 100644 --- a/crates/algo/src/distort.rs +++ b/crates/algo/src/distort.rs @@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) { (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0) } +/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse +/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all +/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to +/// unit-circle coordinates `(u, v)` and back. +fn ellipse(b: Rect) -> (f32, f32, f32, f32) { + let (w, h) = (b.width() as f32, b.height() as f32); + (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0) +} + pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = angle.to_radians(); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || rr <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let t = a * (1.0 - r / rr); + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + return (x, y); + } + let t = a * (1.0 - r); let (s, c) = t.sin_cos(); - (cx + dx * c - dy * s, cy + dx * s + dy * c) + (cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy) }) } pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * (r / rr).powf(1.0 - a * 0.5); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = r.powf(1.0 - a * 0.5) / r; + (cx + u * k * hx, cy + v * k * hy) }) } @@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 { } pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec { - let b = ctx.bounds; - let (cx, cy, rr) = centre(b); - let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { let (dx, dy) = (x - cx, y - cy); match mode { SpherizeMode::Normal => { - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * sphere_1d(r / rr, a); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = (dx / hx, dy / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = sphere_1d(r, a) / r; + (cx + u * k * hx, cy + v * k * hy) } SpherizeMode::HorizontalOnly => { if dx.abs() >= hx { diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index e8e45bcaf2..80de7b993e 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15; struct Rng(u64); impl Rng { + /// The next value of the SplitMix64 sequence. fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 @@ -700,11 +697,14 @@ mod parallel_tests { .collect() } - /// Blocks of random brightness: lots of strong corners, no libm in the making. + /// Blocks of random brightness: lots of strong corners, no libm in the making. Only the + /// recorded-hash test below uses it, and that one is Linux x86_64 only. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn blocks(w: usize, h: usize, seed: u64) -> Vec { (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn fnv(parts: &[u64]) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for p in parts { diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index ee472b2296..2dd40198d8 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20; #[derive(Clone)] pub(crate) struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed`. pub(crate) fn new(seed: u64) -> Self { Rng(seed ^ 0x9E37_79B9_7F4A_7C15) } + /// The next value of the SplitMix64 sequence. pub(crate) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]). pub(crate) fn skip(&mut self, n: u64) { diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index c2ff6d2e6c..35c195f399 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -411,6 +411,14 @@ pub enum FilterParams { }, // ---- Render ---- + /// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight + /// sRGB RGBA colours; the cloud size follows the bounds. + Clouds { + seed: u32, + difference: bool, + foreground: [f32; 4], + background: [f32; 4], + }, /// Variance 1–64, strength 1–64; colours straight sRGB RGBA. Fibers { variance: f32, @@ -482,7 +490,8 @@ pub enum FilterParams { distribution: Distribution, monochromatic: bool, seed: u32, - /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller. + /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document + /// coordinates), supplied by the caller. #[serde(skip)] depth_map: Option>, }, @@ -605,7 +614,13 @@ impl FilterParams { /// the reference bounds even where the layer is transparent, as Photoshop's does on an /// empty layer, while still reading no neighbourhood. pub fn renders_from_nothing(&self) -> bool { - matches!(self, FilterParams::Fibers { .. }) + matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. }) + } + + /// Whether the filter places and sizes its pattern by the canvas even with a selection + /// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result. + pub fn sizes_by_canvas(&self) -> bool { + matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. }) } /// Human-readable name. @@ -652,6 +667,8 @@ impl FilterParams { FilterParams::Displace { .. } => "Displace", FilterParams::Shear { .. } => "Shear", FilterParams::ZigZag { .. } => "ZigZag", + FilterParams::Clouds { difference: false, .. } => "Clouds", + FilterParams::Clouds { difference: true, .. } => "Difference Clouds", FilterParams::Fibers { .. } => "Fibers", FilterParams::LensFlare { .. } => "Lens Flare", FilterParams::LightingEffects { .. } => "Lighting Effects", @@ -696,7 +713,7 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)), FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds, // Generators and per-pixel renders only need the bounds geometry (always in the context). - FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0), + FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0), FilterParams::LightingEffects { .. } => r(1.0), FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0), FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()), @@ -744,8 +761,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), FilterParams::Median { radius } => noise::median(src, out, *radius, None), FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), - FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve), - FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve), + FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve), + FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve), FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), @@ -816,6 +833,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec distort2::shear(src, out, ctx, points, *undefined), FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style), + FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)), FilterParams::Fibers { variance, strength, seed, foreground, background } => { render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)) } @@ -909,6 +927,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec Vec { + if !fxutil::subtractive(ctx) { + return other::min_max_preserve(src, out, radius, max, preserve); + } + let n = src.ch.max(1); + let cc = if ctx.alpha { n - 1 } else { n }; + let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v)); + let mut inv = src.clone(); + flip(&mut inv.data); + let mut res = other::min_max_preserve(&inv, out, radius, max, preserve); + flip(&mut res); + res +} + /// Output tile size used by [`apply`]. pub const TILE: i32 = 256; @@ -972,6 +1007,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). +/// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( surface: &Surface, params: &FilterParams, @@ -1014,6 +1050,8 @@ fn apply_tiled_with_impl( ctl: &photocraft_raster::Interrupt, prepare_fast_paths: bool, ) -> Option { + // Every dispatch path needs a positive step, including Motion Blur's fallback. + let tile = tile.max(1); if ctl.cancelled() { return None; } diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index adbca8680f..ee678d1aa9 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -19,6 +19,7 @@ //! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and //! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things. +use crate::par; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; @@ -186,6 +187,10 @@ struct StrokeCursor { carry: f64, } +/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about +/// 125,000); smaller ones are not worth the hand-off. +const PARALLEL_DAB_NODES: usize = 16_384; + /// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a /// drag is many small pushes and its edge stays smooth. fn dab_spacing(r: f64) -> f64 { @@ -487,9 +492,11 @@ impl LiquifyField { bilinear(&self.d, self.w, self.h, gx, gy) } }; - let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1)); + // Every node reads only the snapshot and the field as it was before this dab (the writes + // land after), so rows are independent: a large brush computes them in parallel. let inv_cell = 1.0 / self.cell; - for j in j0..=j1 { + let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> { + let mut row = Vec::with_capacity(i1 - i0 + 1); for i in i0..=i1 { let p = self.node_pos(i, j); let (dx, dy) = (p[0] - c[0], p[1] - c[1]); @@ -503,7 +510,7 @@ impl LiquifyField { if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw { let f = self.freeze[idx]; let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) }; - writes.push((idx, self.d[idx], v as f32)); + row.push((idx, self.d[idx], v as f32)); continue; } let k = k_raw * (1.0 - f64::from(self.freeze[idx])); @@ -553,10 +560,13 @@ impl LiquifyField { [(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32] } }; - writes.push((idx, new, self.freeze[idx])); + row.push((idx, new, self.freeze[idx])); } - } - for (idx, d, f) in writes { + row + }; + let rows = j1 - j0 + 1; + let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row)); + for (idx, d, f) in writes.into_iter().flatten() { self.d[idx] = d; self.freeze[idx] = f; } @@ -653,7 +663,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { if field.is_identity() || b.is_empty() { return out; } - let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3; + // A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D + // interpolated displacement can reach 25/16 of the largest nodal displacement. + let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3); + let has_freeze = field.freeze.iter().any(|&f| f > 0.0); let tiles: Vec = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect(); let work = |t: &Rect| -> Option<(Rect, Vec)> { let w = t.width() as usize; @@ -670,7 +683,15 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { let px = f64::from(t.x0) + x as f64 + 0.5; let gx = (px - f64::from(b.x0) - 0.5) / field.cell; let wx = catmull_rom(gx - gx.floor()); - let d = field.sample_cubic(gx, gy, &wx, &wy); + let mut d = field.sample_cubic(gx, gy, &wx, &wy); + if has_freeze { + let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0); + if mask > 0.0 { + let linear = field.sample(px, py); + d[0] = d[0] * (1.0 - mask) + linear[0] * mask; + d[1] = d[1] * (1.0 - mask) + linear[1] * mask; + } + } if d[0] != 0.0 || d[1] != 0.0 { moved = true; } @@ -920,18 +941,23 @@ impl ProxyImage { return false; } source_scratch.resize(source_len, [0.0; 4]); - src.read_rgba_into(src_rect, source_scratch); - if !src.format().alpha { - for pixel in source_scratch.iter_mut() { - pixel[3] = 1.0; + // Read and premultiply the source in one pass per band of rows, the bands in parallel: at + // an 800 px brush it is about a million pixels (P30). + let opaque = !src.format().alpha; + const BAND_ROWS: usize = 32; + par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| { + let y = src_rect.y0 + (band_index * BAND_ROWS) as i32; + src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band); + for pixel in band.iter_mut() { + if opaque { + pixel[3] = 1.0; + } + let alpha = pixel[3]; + pixel[0] *= alpha; + pixel[1] *= alpha; + pixel[2] *= alpha; } - } - for pixel in source_scratch.iter_mut() { - let alpha = pixel[3]; - pixel[0] *= alpha; - pixel[1] *= alpha; - pixel[2] *= alpha; - } + }); let x0 = (region.x0 - bounds.x0) as usize; let x1 = (region.x1 - bounds.x0) as usize; @@ -1376,6 +1402,42 @@ mod tests { assert!(g.freeze.iter().all(|f| *f == 0.0)); } + #[test] + fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() { + let bounds = bounds(); + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for y in 0..bounds.height() { + for x in 0..bounds.width() { + src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]); + } + } + + let mut mask_only = LiquifyField::new(bounds, 2.0); + mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None)); + assert_eq!(apply_liquify(&src, &mask_only), src); + + let mut field = LiquifyField::new(bounds, 2.0); + let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0); + first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]]; + field.apply_stroke(&first_warp); + + field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None)); + let frozen_pixel = (37, 32); + assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999); + let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1); + assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp"); + let neighbor_before = apply_liquify(&src, &field).pixel(43, 32); + + let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0); + adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]]; + field.apply_stroke(&adjacent_warp); + + let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1); + let neighbor_after = apply_liquify(&src, &field).pixel(43, 32); + assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping"); + assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move"); + } + #[test] fn twirl_pucker_bloat_and_push_left_have_the_right_sense() { let at = |tool: LiquifyTool, pts: Vec>| { diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index b5a16f06ea..aa76fb814a 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -8,7 +8,8 @@ use crate::image::Image; use crate::{Ctx, HsbModel}; /// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha -/// kept). Kernels shorter than 25 entries are zero-padded. +/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are +/// ink, so there it runs on `1 - ink` and a positive offset brightens. pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec { let n = src.ch; let cc = ncol(ctx, n); @@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect(); let s = if scale == 0.0 { 1.0 } else { scale }; let off = offset / 255.0; + let sub = crate::fxutil::subtractive(ctx); let mut res = src.crop(out); if k.is_empty() { return res; @@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f for (i, px) in res.chunks_exact_mut(n).enumerate() { let (x, y) = xy(out, i); for (c, v) in px.iter_mut().enumerate().take(cc) { - let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum(); - *v = sum / s + off; + // CMYK: convolve brightness (1 - ink), then back to ink. + let lum = |v: f32| if sub { 1.0 - v } else { v }; + let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum(); + *v = lum(sum / s + off); } } res @@ -120,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output #[cfg(test)] mod tests { use super::*; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) { + let b = Rect::new(-2, -1, 3, 4); + let format = PixelFormat::new(mode, depth, alpha); + let mut src = Surface::new(format); + let mut pixels = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + for c in 0..format.channels() { + pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0); + } + } + } + src.write_region(b, &pixels); + (src, b) + } + + fn assert_sample(actual: f32, expected: f32, depth: SampleType) { + let (expected, tolerance) = match depth { + SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0), + SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0), + SampleType::F32 => (expected, 1e-6), + }; + assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}"); + } + + fn run_custom(src: &Surface, b: Rect, kernel: Vec, scale: f32, offset: f32) -> Surface { + crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b) + } + + #[test] + fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + for tap in 0..25 { + let mut kernel = vec![0.0; 25]; + kernel[tap] = 1.0; + let out = run_custom(&src, b, kernel, 1.0, 0.0); + let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1)); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], expected[c], depth); + } + assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift"); + } + } + } + } + } + + #[test] + fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let mut short = vec![0.0; 13]; + short[0] = 0.25; + short[12] = 0.75; + let mut padded = short.clone(); + padded.resize(25, 0.0); + let mut long = padded.clone(); + long.extend([1e6, -1e6, f32::NAN]); + let out = run_custom(&src, b, short, 1.0, 0.0); + assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b)); + assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0)); + let centre = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth); + } + } + } + } + } + + #[test] + fn custom_scale_zero_is_one_and_offset_is_in_255ths() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let before = src.read_region(b); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0); + assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() { + for depth in DEPTHS { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let (src, b) = custom_fixture(depth, mode, true); + let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0); + let alpha = src.channels() - 1; + assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour"); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}"); + } + } + } + } + } + + #[test] + fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() { + for depth in DEPTHS { + for alpha in [false, true] { + let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha); + for offset in [-51.0, 51.0] { + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, offset); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..4 { + // Twice the reflected brightness, plus offset, then back to ink. + assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth); + } + if alpha { + assert_eq!(actual[4], original[4]); + } + } + } + } + } + } + } + + #[test] + fn custom_float_retains_hdr_while_integer_storage_clamps() { + let b = Rect::new(0, 0, 1, 1); + for depth in DEPTHS { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, -51.0); + let actual = out.pixel(0, 0); + let original = src.pixel(0, 0); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + if depth == SampleType::F32 { + assert!(actual[0] < 0.0 && actual[2] > 1.0); + } else { + assert_eq!((actual[0], actual[2]), (0.0, 1.0)); + } + } + } #[test] fn hsb_hsl_round_trip() { diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index c32f28c648..8146af2582 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -89,10 +89,7 @@ fn composite_area( let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; let px = &mut region[i..i + n]; let d = photocraft_raster::to_rgba(&fmt, px); - let mut o = blend::composite(blend_mode, d, color(x, y), k); - if lock_transparency { - o[3] = d[3]; - } + let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) }; from_rgba_into(&fmt, o, &mut enc); // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. if let Some(i) = inks @@ -296,6 +293,22 @@ mod tests { assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection"); } + #[test] + fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() { + // Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783). + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for mode in [BlendMode::Normal, BlendMode::Multiply] { + let mut s = Surface::new(fmt); + let a = Rect::new(0, 0, 4, 1); + s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]); + let black = [0.0, 0.0, 0.0, 1.0]; + paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true); + let p = s.rgba(2, 0); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}"); + } + } + } + #[test] fn bucket_fills_contiguous_region() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index abe17db395..87f8415b99 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o label[i] = img as i32; } } - if no == 0 || nn == no { + if no == 0 { continue; } - let (ox, oy) = (sx / no as f64, sy / no as f64); let (cx, cy) = (nx / nn as f64, ny / nn as f64); + let contained = nn == no; + let (ox, oy) = if contained { + // Wholly inside the composite (#5745: two photos in one document, the second moved + // by Auto-Align so the canvas crops it to the part over the first). Its side of the + // seam is the one it lies towards from the composite's centre; an image centred on + // the composite has no side, and the earlier images keep it. + let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize); + for (i, l) in label.iter().enumerate() { + if *l >= 0 { + px += (i % w) as f64; + py += (i / w) as f64; + pn += 1; + } + } + let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64); + if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 { + continue; + } + (px, py) + } else { + (sx / no as f64, sy / no as f64) + }; let vertical = (cx - ox).abs() >= (cy - oy).abs(); // Image on the increasing side along the seam's cross axis? let positive = if vertical { cx > ox } else { cy > oy }; + // A contained image has no pixels of its own, so a seam on its outer edge would leave it + // nothing: break cost ties away from its side (towards larger k when it lies on the + // decreasing side) so it keeps its side of the overlap. + let prefer_high = contained && !positive; let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() }; let (lines, len) = if vertical { (h, w) } else { (w, h) }; let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line }; @@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o let mut best = (0.0f32, usize::MAX); if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) { best = (f32::INFINITY, usize::MAX); - for dk in [-1i64, 0, 1] { + let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] }; + for dk in dks { let kk = k as i64 + dk; if kk < 0 || kk >= len as i64 { continue; @@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if seam[line] != usize::MAX { continue; } - let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue }; + let Some(k) = (0..len) + .filter(|&k| overlap[idx(line, k)]) + .min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) })) + else { + continue; + }; let (mut l, mut k) = (line, k); loop { seam[l] = k; @@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if s == usize::MAX { continue; } + let on_side = |k: usize| if positive { k > s } else { k < s }; + // A contained image has no other cells, so where the overlap has nothing on its side + // of the seam (a line one cell wide) it takes the seam cell rather than nothing. + let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k)); for k in 0..len { let i = idx(line, k); - if overlap[i] && ((positive && k > s) || (!positive && k < s)) { + if overlap[i] && (on_side(k) || (take_seam && k == s)) { label[i] = img as i32; } } @@ -1170,10 +1205,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize }) } +/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built +/// and accumulated in batches under it, so its peak memory doesn't grow with the image count +/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side +/// uses a many-core machine better than one band's row-parallel work, and one at a time made the +/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop. +#[cfg(not(target_arch = "wasm32"))] +const BAND_BUDGET: usize = 5 << 30; +/// Without threads the bands of a batch are built one after another anyway, so each is added as +/// soon as it is built (the browser's address space is 4 GB). +#[cfg(target_arch = "wasm32")] +const BAND_BUDGET: usize = 0; + /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image /// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour /// channels (`ch` per pixel) and the total coverage. pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET) +} + +/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one +/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it. +fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) { let ch = imgs.first().map_or(1, |i| i.ch); let levels = levels.max(1); let align = 1i32 << (levels - 1); @@ -1187,13 +1240,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect(); let margin = align * 4; // Each image on its ROI grown by a margin and aligned to the coarsest level's grid. - let bands = par_map(imgs.len(), |k| { - let im = &imgs[k]; + let grid = |im: &RoiImage| { let gx0 = ((im.x0 - margin).max(0) / align) * align; let gy0 = ((im.y0 - margin).max(0) / align) * align; let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); - let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize); + (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) + }; + // Building one band holds its grid's colour and weight planes about three times over: the + // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top. + let cost = |im: &RoiImage| { + let (_, _, gw, gh) = grid(im); + gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) + }; + let band = |k: usize| -> PyramidBand { + let im = &imgs[k]; + let (gx0, gy0, gw, gh) = grid(im); // Each grid pixel takes the image pixel that lands on it (every image pixel lands on at // most one), so rows of the grid are independent and fill in parallel. let src_of = |gx: usize, gy: usize| -> Option { @@ -1225,68 +1287,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } }); let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); + drop(cov); push_pull(gw, gh, ch, &mut px, &bin); - // Laplacian of the colours, Gaussian of the weights. + drop(bin); + // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather + // than being copied: with one image built at a time, a copy is a serial pass over it. let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); let mut gau: Vec> = Vec::new(); let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt); for l in 0..levels { if l + 1 == levels { - lap.push((cw, chh, cur.clone())); - gau.push(cwt.clone()); + lap.push((cw, chh, cur)); + gau.push(cwt); break; } let (nw, nh, down) = reduce(cw, chh, ch, &cur); let up = expand(nw, nh, ch, &down, cw, chh); lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); - gau.push(cwt.clone()); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); + gau.push(cwt); cw = nw; chh = nh; cur = down; cwt = wdown; } (gx0, gy0, lap, gau) - }); - // Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per - // level, bands one after another), but rows of a level are independent, so they run in - // parallel: every row walks the bands itself. - for l in 0..levels { - let pw = sizes[l].0; - par_rows(&mut wsum[l], pw, 1, |py, row| { - for b in &bands { - let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - row[px] += k; - } - } - }); - par_rows(&mut acc[l], pw, ch, |py, row| { - for b in &bands { - let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - for c in 0..ch { - row[px * ch + c] += k * band[x * ch + c]; + }; + // Build the bands a batch at a time and add each batch before building the next. Each pixel + // adds the bands in the same order as the serial loop (per level, bands one after another; + // batches follow each other in image order), but rows of a level are independent, so they + // run in parallel: every row walks the batch's bands itself. + let mut start = 0; + while start < imgs.len() { + let mut end = start + 1; + let mut bytes = cost(&imgs[start]); + while let Some(im) = imgs.get(end) + && bytes.saturating_add(cost(im)) <= budget + { + bytes = bytes.saturating_add(cost(im)); + end += 1; + } + let bands = par_map(end - start, |j| band(start + j)); + for l in 0..levels { + let pw = sizes[l].0; + par_rows(&mut wsum[l], pw, 1, |py, row| { + for b in &bands { + let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + row[px] += k; } } - } - }); + }); + par_rows(&mut acc[l], pw, ch, |py, row| { + for b in &bands { + let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + for c in 0..ch { + row[px * ch + c] += k * band[x * ch + c]; + } + } + } + }); + } + start = end; } for l in 0..levels { let pw = sizes[l].0; @@ -1541,6 +1620,58 @@ mod tests { assert!(max_jump < 0.1, "max step {max_jump}"); assert!(cov.iter().all(|c| *c == 1.0)); } + + #[test] + fn an_image_wholly_inside_the_composite_still_gets_its_side() { + // #5745: the first image covers the whole 120×40 grid, the second only its right part + // (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything. + let (w, h) = (120usize, 40usize); + let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]]; + let cover: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()]; + let labels = seam_labels(w, h, &luma, &cover, &[0, 1]); + for y in 0..h { + assert_eq!(labels[y * w + 5], 0, "row {y}"); + assert_eq!(labels[y * w + w - 1], 1, "row {y}"); + // The second image's region is one run at the right of its seam. + let row = &labels[y * w..(y + 1) * w]; + let first = row.iter().position(|l| *l == 1).unwrap(); + assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + // The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap + // instead of on the image's outer edge, which used to leave it nothing. + let left: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()]; + let labels = seam_labels(w, h, &luma, &left, &[0, 1]); + for y in 0..h { + let row = &labels[y * w..(y + 1) * w]; + assert_eq!(row[0], 1, "row {y}: {row:?}"); + assert_eq!(row[w - 1], 0, "row {y}"); + let last = row.iter().rposition(|l| *l == 1).unwrap(); + assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + let (tw, th) = (40usize, 120usize); + let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]]; + let top: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()]; + let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]); + for x in 0..tw { + assert_eq!(labels[x], 1, "column {x}"); + assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}"); + } + // One column or row wide: the seam can only run through it, and the image keeps it. + for col in [0, w - 1] { + let strip: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()]; + let labels = seam_labels(w, h, &luma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}"); + } + let strip: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()]; + let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}"); + // An image centred on the composite has no side: the first image keeps the overlap. + let centred: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()]; + assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0)); + // Two images covering the same cells, likewise. + let same = vec![vec![true; w * h]; 2]; + assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0)); + } } /// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they @@ -1910,6 +2041,28 @@ mod parallel_tests { assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); } + /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none + /// (one image per batch) up to everything in steps of a fifth pass through every split of the + /// three images. + #[test] + fn multiband_batches_match_the_serial_code() { + let budgets: Vec = + std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect(); + for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64); + for levels in [1usize, 3, 5] { + let want = ref_multiband(w, h, &imgs, &wts, levels); + for &budget in &budgets { + let got = multiband_batched(w, h, &imgs, &wts, levels, budget); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}"); + } + } + } + } + } + /// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so /// they hold on every platform). #[test] diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs index 3a9572fb46..2349604196 100644 --- a/crates/algo/src/params_ext.rs +++ b/crates/algo/src/params_ext.rs @@ -182,6 +182,9 @@ pub enum DepthSource { None, Transparency, LayerMask, + /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the + /// caller like the layer mask. + Channel, } /// Built-in Shape Blur kernels. diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index bf65bc4459..7316088079 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -159,15 +159,21 @@ pub(crate) fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) - (0..n).map(f).collect() } +/// Advances `state` one SplitMix64 step and returns the mixed output. +pub(crate) fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + /// Deterministic 64-bit generator (SplitMix64). pub(crate) struct Rng(pub u64); impl Rng { + /// The next value of the SplitMix64 sequence. pub fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + splitmix64(&mut self.0) } pub fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize @@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 { (z >> 40) as f32 / (1u64 << 24) as f32 } -/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1). -pub(crate) fn srgb_to_linear(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - -/// sRGB transfer function, encode. -pub(crate) fn linear_to_srgb(v: f32) -> f32 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } -} +pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; /// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges. pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 { @@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit. + #[test] + fn splitmix64_output_is_unchanged() { + /// The SplitMix64 step as each generator inlined it before. + fn inlined(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + let mut zero = 0; + assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]); + for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] { + let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed)); + for _ in 0..10_000 { + let want = inlined(&mut copy); + assert_eq!(splitmix64(&mut shared), want); + assert_eq!(rng.next(), want); + } + } + } + + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + #[test] fn dense_and_sparse_solvers_agree() { // Overdetermined line fit y = 2x + 1. diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index 589c9f196d..70d5b5c90c 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -61,8 +61,15 @@ pub enum Dither { Noise, } +/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call +/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating +/// with an integer cast and adding one when the exact remainder is at least a half is +/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The +/// `to8_matches_round_for_every_float` test checks every input. fn to8(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 + let x = v.clamp(0.0, 1.0) * 255.0; + let t = x as u32; + (t + u32::from(x - t as f32 >= 0.5)) as u8 } fn rgb8(p: &[f32; 4]) -> Rgb8 { @@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec { } } -/// Unique 8-bit colours with counts (opaque pixels only). +/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour. fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> { - let mut m: std::collections::HashMap = std::collections::HashMap::new(); - for p in px.iter().filter(|p| p[3] >= 0.5) { - *m.entry(rgb8(p)).or_default() += 1; + // Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the + // `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash + // `HashMap` insert per pixel: counts are integers, so any order of work gives the same + // result, and equal colours sit next to each other after the sort. One 4-byte value per + // pixel is the only large allocation (no per-thread tables). + let pack = |p: &[f32; 4]| { + let [r, g, b] = rgb8(p); + u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b) + }; + #[cfg(not(target_arch = "wasm32"))] + let keys = { + use rayon::prelude::*; + let mut keys: Vec = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.par_sort_unstable(); + keys + }; + #[cfg(target_arch = "wasm32")] + let keys = { + let mut keys: Vec = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.sort_unstable(); + keys + }; + let mut out: Vec<(Rgb8, u32)> = Vec::new(); + for k in keys { + let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8]; + match out.last_mut() { + Some((last, n)) if *last == c => *n = n.saturating_add(1), + _ => out.push((c, 1)), + } + } + out +} + +/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot +/// be split. Every score is bit for bit what the old per-iteration scan gave. +fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> { + if b.len() <= 1 { + return None; + } + // One pass per axis, then the weight sum: the same folds the old per-iteration scan ran + // (measured faster than a single fused pass, which the compiler does not vectorise). + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + Some((best.0, best.1 * w.sqrt())) +} + +/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order. +fn total_order_key(v: f32) -> u32 { + let bits = v.to_bits(); + // Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit. + bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000) +} + +/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like +/// `sort_by`, so ties keep their original order and the result is element for element the same; +/// it is several times faster than a comparison sort on millions of points. +fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + let n = b.len(); + let Some(&fill) = b.first() else { return }; + if n < 2 { + return; + } + // Three digits of 11, 11 and 10 bits; count all of them in one read. + let mut counts = [[0u32; 2048]; 3]; + for e in b.iter() { + let k = total_order_key(e.0[axis]); + counts[0][(k & 0x7ff) as usize] += 1; + counts[1][((k >> 11) & 0x7ff) as usize] += 1; + counts[2][(k >> 22) as usize] += 1; + } + let mut src = std::mem::take(b); + let mut dst = vec![fill; n]; + for (pass, count) in counts.iter_mut().enumerate() { + // A digit that is the same for every point would not move anything. + if count.iter().any(|&c| c as usize == n) { + continue; + } + let mut sum = 0u32; + for c in count.iter_mut() { + let here = *c; + *c = sum; + sum += here; + } + let shift = 11 * pass as u32; + for e in &src { + let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize; + dst[count[d] as usize] = *e; + count[d] += 1; + } + std::mem::swap(&mut src, &mut dst); + } + *b = src; +} + +/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the +/// radix sort's fixed counting cost. +fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + if b.len() >= 256 { + radix_sort_along(b, axis); + } else { + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); } - let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect(); - v.sort_unstable(); - v } /// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means. @@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { return Vec::new(); } let mut boxes: Vec> = vec![pts.to_vec()]; + // Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes). + // Only the two boxes made by a split change, so only those are scanned again; rescanning + // every box on every iteration was most of the time here. + let mut scores: Vec> = vec![split_score(pts)]; while boxes.len() < n { - // Split the box with the largest weighted extent. - let (bi, axis, score) = boxes + // Split the box with the largest weighted extent (first one wins a tie, as before). + let (bi, axis, score) = scores .iter() .enumerate() - .filter(|(_, b)| b.len() > 1) - .map(|(i, b)| { - let mut best = (0usize, -1.0f32); - for a in 0..3 { - let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); - if hi - lo > best.1 { - best = (a, hi - lo); - } - } - let w: f32 = b.iter().map(|p| p.1 as f32).sum(); - (i, best.0, best.1 * w.sqrt()) - }) + .filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score))) .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); if bi == usize::MAX || score <= 0.0 { break; } let mut b = boxes.swap_remove(bi); - b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + scores.swap_remove(bi); + sort_along(&mut b, axis); let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); let mut acc = 0u64; let mut cut = 1; @@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { } } let rest = b.split_off(cut); + scores.push(split_score(&b)); + scores.push(split_score(&rest)); boxes.push(b); boxes.push(rest); } @@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { let lookup = Nearest::new(centers.iter().copied()); let mut sum = vec![[0.0f64; 3]; centers.len()]; let mut cnt = vec![0.0f64; centers.len()]; - for (p, w) in pts { - let i = lookup.find(*p); + // The nearest-centre search is the expensive part and has no cross-point state, so it + // runs in parallel. The f64 sums below stay sequential in the original point order: + // float addition is not associative, and this keeps every bit of the result unchanged. + #[cfg(not(target_arch = "wasm32"))] + let nearest: Vec = { + use rayon::prelude::*; + pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect() + }; + #[cfg(target_arch = "wasm32")] + let nearest: Vec = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect(); + for ((p, w), &i) in pts.iter().zip(&nearest) { + let i = i as usize; for a in 0..3 { sum[i][a] += f64::from(p[a]) * f64::from(*w); } @@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 { photocraft_color::convert::lab_to_srgb(lab).map(to8) } +/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own +/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow +/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours. +fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> { + let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + hist.par_iter().map(lab).collect() + } + #[cfg(target_arch = "wasm32")] + hist.iter().map(lab).collect() +} + /// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed /// kinds; forced colours are included first and count toward it. pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result, String> { @@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: if hist.len() <= room { hist.iter().map(|h| h.0).collect() } else { - let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect(); + let pts = lab_points(&hist); let mut centers = median_cut(&pts, room); kmeans(&pts, &mut centers, 4); let mut v: Vec = centers.iter().map(|c| lab_to_rgb8(*c)).collect(); @@ -562,6 +690,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { } } +/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major): +/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel +/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly +/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does +/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give +/// exactly those levels). +pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec { + let n = lightness.len(); + let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + let mut order: Vec = (0..n).collect(); + order.sort_by(|a, b| key(*a).total_cmp(&key(*b))); + let mut out = vec![0.5; n]; + let mut start = 0; + while start < n { + let v = order.get(start).map(|i| key(*i)); + let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n); + // Ranks start..end share their mean. + let rank = (start + end - 1) as f32 / 2.0; + let t = 1.0 - (rank + 0.5) / n as f32; + for i in order.get(start..end).unwrap_or_default() { + if let Some(o) = out.get_mut(*i) { + *o = t; + } + } + start = end; + } + out +} + +/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it +/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the +/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%. +pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) { + let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len()); + for (i, v) in gray.iter_mut().enumerate() { + let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 }; + *v = if *v >= t { 1.0 } else { 0.0 }; + } +} + #[cfg(test)] mod tests { use super::*; @@ -570,6 +738,253 @@ mod tests { (0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect() } + fn to8_reference(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + + #[test] + fn to8_matches_round_on_samples_and_edges() { + // Every half-way point and its neighbours, plus a dense sweep of the unit range. + for k in 0..=256u32 { + let mid = (k as f32 + 0.5) / 255.0; + for d in -3i32..=3 { + let v = f32::from_bits((mid.to_bits() as i32 + d) as u32); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + for i in 0..200_000u32 { + let v = f32::from_bits(i * 20_000); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] { + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"] + fn to8_matches_round_for_every_float() { + use rayon::prelude::*; + let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count(); + assert_eq!(bad, 0); + } + + /// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a + /// few see-through pixels. + fn golden_image() -> Vec<[f32; 4]> { + let (w, h) = (96usize, 64usize); + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0; + let alpha = if i % 17 == 0 { 0.25 } else { 1.0 }; + [x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha] + }) + .collect() + } + + /// Palettes written down from the version before the speed-up (hash-map histogram, every box + /// rescanned each split, comparison sort, serial k-means). Any change to how the palettes + /// are built has to give these exact colours. + #[test] + fn palettes_match_the_recorded_originals() { + let px = golden_image(); + #[rustfmt::skip] + let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![ + ( + PaletteKind::Adaptive, + 16, + Forced::None, + &[ + [40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49], + [142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87], + [91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127], + ], + ), + ( + PaletteKind::Perceptual, + 16, + Forced::None, + &[ + [104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107], + [200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56], + [119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135], + ], + ), + ( + PaletteKind::Selective, + 24, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76], + [164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45], + [153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80], + [32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94], + ], + ), + ( + PaletteKind::Adaptive, + 8, + Forced::Primaries, + &[ + [0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], + [255, 0, 255], [255, 255, 0], + ], + ), + ( + PaletteKind::Perceptual, + 12, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132], + [66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105], + ], + ), + ]; + for (kind, colors, forced, expected) in cases { + assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}"); + } + } + + /// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones. + fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { + if pts.is_empty() || n == 0 { + return Vec::new(); + } + let mut boxes: Vec> = vec![pts.to_vec()]; + while boxes.len() < n { + let (bi, axis, score) = boxes + .iter() + .enumerate() + .filter(|(_, b)| b.len() > 1) + .map(|(i, b)| { + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + (i, best.0, best.1 * w.sqrt()) + }) + .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); + if bi == usize::MAX || score <= 0.0 { + break; + } + let mut b = boxes.swap_remove(bi); + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); + let mut acc = 0u64; + let mut cut = 1; + for (i, p) in b.iter().enumerate() { + acc += u64::from(p.1); + if acc * 2 >= total { + cut = (i + 1).clamp(1, b.len() - 1); + break; + } + } + let rest = b.split_off(cut); + boxes.push(b); + boxes.push(rest); + } + boxes + .iter() + .map(|b| { + let w: f64 = b.iter().map(|p| f64::from(p.1)).sum(); + std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::() / w) as f32) + }) + .collect() + } + + fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { + for _ in 0..iters { + let lookup = Nearest::new(centers.iter().copied()); + let mut sum = vec![[0.0f64; 3]; centers.len()]; + let mut cnt = vec![0.0f64; centers.len()]; + for (p, w) in pts { + let i = lookup.find(*p); + for a in 0..3 { + sum[i][a] += f64::from(p[a]) * f64::from(*w); + } + cnt[i] += f64::from(*w); + } + for (i, c) in centers.iter_mut().enumerate() { + if cnt[i] > 0.0 { + *c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32); + } + } + } + } + + /// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to + /// a few thousand, so the stable-sort and float-order rules are all exercised. + fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> { + let mut s = seed; + let mut next = move || { + s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (s >> 33) as u32 + }; + (0..n) + .map(|_| { + let mut c = |range: u32| match next() % 40 { + 0 => -0.0, + 1 => 0.0, + _ => (next() % range) as f32 * 0.5 - (range / 4) as f32, + }; + ([c(24), c(300), c(150)], 1 + next() % 3000) + }) + .collect() + } + + #[test] + fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() { + // 40 000 points cross the 256-point radix threshold on the first splits. + for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] { + let pts = tie_heavy_points(n, seed); + let new = median_cut(&pts, boxes); + let old = median_cut_reference(&pts, boxes); + assert_eq!(new.len(), old.len()); + for (a, b) in new.iter().zip(&old) { + assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}"); + } + let (mut a, mut b) = (new, old); + kmeans(&pts, &mut a, 3); + kmeans_reference(&pts, &mut b, 3); + for (x, y) in a.iter().zip(&b) { + assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}"); + } + } + } + + #[test] + fn radix_sort_is_the_stable_total_cmp_sort() { + for axis in 0..3 { + let mut pts = tie_heavy_points(5_000, 9 + axis as u64); + // The weight field doubles as an id: equal keys must keep their input order. + for (i, p) in pts.iter_mut().enumerate() { + p.1 = i as u32; + } + pts[10].0[axis] = f32::NAN; + pts[11].0[axis] = -f32::NAN; + pts[12].0[axis] = f32::INFINITY; + pts[13].0[axis] = f32::NEG_INFINITY; + let mut expected = pts.clone(); + expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + radix_sort_along(&mut pts, axis); + let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::>(); + assert_eq!(bits(&pts), bits(&expected), "axis {axis}"); + } + } + + #[test] + fn histogram_counts_opaque_pixels_sorted() { + let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]]; + assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]); + assert!(histogram(&[]).is_empty()); + } + #[test] fn fixed_palettes_have_expected_sizes() { assert_eq!(web_palette().len(), 216); @@ -722,4 +1137,41 @@ mod tests { } } } + + /// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white. + fn first_white(t: f32) -> u32 { + (0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256) + } + + #[test] + fn custom_pattern_thresholds_match_photoshop() { + // Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white + // level per pattern pixel, measured against defined patterns. + let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::>(); + let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240])); + assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::>(), [237, 201, 164, 128, 92, 55, 19]); + let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150])); + assert_eq!(five.iter().map(|t| first_white(*t)).collect::>(), [230, 77, 179, 26, 128]); + // Equal pixels share their rank: 100, 100 both sit at 50%. + let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50])); + assert_eq!(ties.iter().map(|t| first_white(*t)).collect::>(), [128, 128, 32, 224]); + assert!(custom_pattern_thresholds(&[]).is_empty()); + assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2); + } + + #[test] + fn custom_pattern_tiles_from_the_top_left() { + // A 2x1 map: even columns need 75%, odd ones 25%. + let map = [0.75, 0.25]; + let mut g = vec![0.5; 6]; + to_bitmap_custom(&mut g, 3, &map, 2, 1); + assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]); + // A map that doesn't match its size, or an empty image width, is a 50% threshold. + let mut g = vec![0.4, 0.6]; + to_bitmap_custom(&mut g, 2, &map, 3, 1); + assert_eq!(g, [0.0, 1.0]); + let mut g = vec![0.6]; + to_bitmap_custom(&mut g, 0, &map, 2, 1); + assert_eq!(g, [1.0]); + } } diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index baba88f93e..2a1867dac8 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -1,4 +1,4 @@ -//! Render: Fibers, Lens Flare, Lighting Effects. +//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects. use photocraft_geom::Rect; @@ -18,6 +18,57 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 { top + (bot - top) * sy } +fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); + h ^= h >> 13; + h = h.wrapping_mul(0x5bd1_e995); + h ^= h >> 15; + (h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32 +} + +/// Smooth value noise, summed over octaves (fBm), in 0..1. +fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 { + let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0); + for o in 0..8u32 { + let (fx, fy) = (x * freq, y * freq); + let (ix, iy) = (fx.floor() as i32, fy.floor() as i32); + let (tx, ty) = (fx - ix as f32, fy - iy as f32); + let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty)); + let s = seed.wrapping_add(o * 7919); + let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx; + let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx; + sum += (top + (bot - top) * sy) * amp; + norm += amp; + amp *= 0.5; + freq *= 2.0; + } + // Stretch the bell-shaped sum so the clouds use the full foreground→background range. + ((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0) +} + +/// Clouds between the foreground and background colours; Difference Clouds subtracts them from +/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side, and the +/// pattern is anchored at the bounds' corner (so it moves with a smart object). +pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec { + let n = src.ch; + let (fg, bg) = colours; + let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0); + let mut res = src.crop(out); + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = xy(out, i); + let (x, y) = (x - ctx.bounds.x0, y - ctx.bounds.y0); + let t = clouds_value(x as f32, y as f32, base, seed); + let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t); + if difference { + let p = rgba(ctx, px); + set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]); + } else { + set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]); + } + } + res +} + /// Fibers: vertical fibrous streaks between the foreground and background /// colours. Variance sets how quickly the colour varies across fibres (and /// how short they are); strength stretches each fibre vertically. @@ -32,8 +83,9 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength: let fy = fx / (2.0 + st * 1.5) * (0.5 + var / 32.0); let mut res = src.crop(out); for (i, px) in res.chunks_exact_mut(n).enumerate() { + // Anchored at the bounds' corner, so the fibres move with a smart object. let (x, y) = xy(out, i); - let (xf, yf) = (x as f32, y as f32); + let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32); let mut sum = 0.0; let mut amp = 1.0; let mut norm = 0.0; @@ -146,8 +198,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen let mut c = rgba(ctx, px); for k in 0..3 { let a = (f[k] * bright).max(0.0); - // Screen keeps highlights below white for display-referred values. - c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0)); + // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit + // value already above white is left as is rather than pulled down towards it. + c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0); } set_rgba(ctx, px, c); } diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index ed6567ca5c..19a4b8218d 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O pub struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed` and `salt`. pub fn new(seed: u32, salt: u64) -> Rng { Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D) } + /// The next value of the SplitMix64 sequence. pub fn next_u64(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Uniform in [0, 1). pub fn f(&mut self) -> f32 { diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index f7c1c18557..00388fe399 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect(); let ks: f32 = k.iter().sum(); let k: Vec = k.into_iter().map(|v| v / ks).collect(); - // Horizontal pass over the window rows extended vertically by r. + // Horizontal pass over the window rows extended vertically by r. Rows are independent, so + // both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426). let th = oh + 2 * r as usize; let stride = ow * ch; let mut tmp = vec![0.0f32; stride * th]; - let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch]; - for ty in 0..th { + let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch]; + let horizontal = |row: &mut Vec, (ty, dst): (usize, &mut [f32])| { let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; // Clamp and premultiply each source pixel once, rather than once for every kernel tap. for (rx, px) in row.chunks_exact_mut(ch).enumerate() { @@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; + *v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; } } } } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(stride).enumerate().for_each(vertical); + } + #[cfg(target_arch = "wasm32")] + { + out.chunks_mut(stride).enumerate().for_each(vertical); } out } diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..95ed668e68 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u } let (fg, bg) = grabcut(&img, &mut trimap, 8)?; let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); - let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); + let low = super::clean_mask(&low, lw, lh, 0.1, 0.0); + // Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } @@ -139,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the /// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both /// by the distance to the seed's running mean and by the local edge step), so it stops at the -/// object's outline. A region that grows over most of the canvas is the background: nothing is -/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// object's outline. A region covering most of the canvas boundary is treated as background. +/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. +/// +/// When the whole-canvas pass finds nothing in a large document (an object only a few working +/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of +/// the same working size around the point, down to full resolution. pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { return None; @@ -152,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u if !canvas.contains(px, py) { return None; } - let step = super::scale_for(canvas, work_px.max(1024)); + let work_px = work_px.max(1024); + let mut step = super::scale_for(canvas, work_px); + if let Some(r) = object_in(sampler, canvas, px, py, step) { + return Some(r); + } + // Side (working pixels) of the square windows of the finer passes. + let side = (work_px as f64).sqrt() as usize; + while step > 1 { + step = (step / 4).max(1); + let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32; + let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas); + if window.width() < 4 || window.height() < 4 { + continue; + } + if let Some(r) = object_in(sampler, window, px, py, step) { + return Some(r); + } + } + None +} + +/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`; +/// `(px, py)` lies inside `canvas`. +fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option { let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); if w < 2 || h < 2 { @@ -164,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let sy = (((py - canvas.y0) as usize) / step).min(h - 1); let grown = grow_similar(&lab, w, h, sx, sy); let count = grown.iter().filter(|g| **g).count(); - // Background (or a flat image): no single object under the cursor. - if count * 10 > w * h * 6 || count < 4 { + let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count(); + // shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788). + if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 { return None; } let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); @@ -209,7 +239,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let mut seeds = vec![false; w * h]; seeds[sy * w + sx] = true; let low = super::keep_seeded(&low, &seeds, w, h); - let low = super::clean_mask(&low, w, h, 0.1, 0.01); + let low = super::clean_mask(&low, w, h, 0.1, 0.0); + // As in the rectangle mode: enclosed holes fill only when they look like the object. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); if !low.iter().any(|v| *v) { return None; } diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..165272231b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() { + let (w, h) = (400, 300); + for margin in [30, 20, 8] { + let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin; + let img = RgbImage::from_fn(w, h, |x, y| { + let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0; + if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] } + }); + let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let sampler = ImageSampler { img: &img, origin: (-40, 25) }; + let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25); + let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX) + .unwrap_or_else(|| panic!("missing large foreground with margin {margin}")); + let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask }; + assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}"); + assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}"); + } +} + +#[test] +fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() { + let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap(); + assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9); + assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1); + let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]); + assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + #[test] fn object_at_on_the_background_or_off_canvas_finds_nothing() { let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); @@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } +/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is). +#[test] +fn object_at_finds_small_object_in_large_canvas() { + let (w, h) = (4000usize, 3000usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] { + let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh; + let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let truth: Vec = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32); + let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX); + let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found")); + let v = iou(®, &truth, w, h); + assert!(v > 0.8, "{ow}×{oh}: IoU {v}"); + } + // The flat background around the small object is still nothing. + let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + // ---------- Select Subject on the cases reported in #2788 ---------- /// A figure whose upper part is close to the background colour (a cream shirt on a near-white @@ -912,3 +966,61 @@ fn select_subject_dump_pictures() { write("ring", &img, None); write("ring-selected", &img, reg.as_ref()); } + +/// A dark square on a light background with a square hole that shows the background colour. +fn square_with_hole(hole: usize) -> RgbImage { + let (w, h) = (400usize, 300usize); + let mut rng = Rng::new(11); + let (x0, y0) = (100usize, 50usize); + RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200; + let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole; + let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }) +} + +#[test] +fn object_select_keeps_small_background_hole_open() { + let img = square_with_hole(8); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn object_at_keeps_small_background_hole_open() { + let img = square_with_hole(16); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn hole_fill_without_specks_fills_object_coloured_holes_only() { + // The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does. + let (w, h) = (40usize, 40usize); + let mut rng = Rng::new(5); + let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y); + let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y); + let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y); + let img = RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }); + let mask: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect(); + // The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour. + let dark: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect(); + let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m); + let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 3, gmm::DEFAULT_REG).unwrap(); + let (fg, bg) = (fit(fs), fit(bs)); + let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05); + assert!(out[25 * w + 25], "a speck of the object's colour fills"); + assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); +} diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs index ca3be5b40f..96c6caeb8f 100644 --- a/crates/algo/src/segment/workflow_tests.rs +++ b/crates/algo/src/segment/workflow_tests.rs @@ -1,4 +1,28 @@ use super::*; + +#[test] +#[ignore = "release Object Finder timing on a 24 MP document"] +#[allow(clippy::assertions_on_constants)] +fn object_finder_large_foreground_release_timing() { + assert!(!cfg!(debug_assertions), "run with --release"); + let (w, h) = (6000, 4000); + let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] { + let start = std::time::Instant::now(); + let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX); + println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + if name == "foreground" { + let region = region.unwrap(); + assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9); + assert!(region.at(10, 10) < 0.1); + } else { + assert!(region.is_none()); + } + } +} + #[test] #[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/algo/src/smart_blur_tests.rs b/crates/algo/src/smart_blur_tests.rs new file mode 100644 index 0000000000..47290e2425 --- /dev/null +++ b/crates/algo/src/smart_blur_tests.rs @@ -0,0 +1,173 @@ +//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an +//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout. +use super::*; +use photocraft_color::ColorMode; + +#[allow(clippy::too_many_arguments)] +fn smart_blur_oracle(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec { + let n = src.ch; + let cc = ncol(ctx, n); + let r = radius.clamp(0.1, 100.0); + let t = threshold.clamp(0.1, 100.0) / 255.0; + let ri = r.ceil() as i32; + let target = match quality { + BlurQuality::Low => 4, + BlurQuality::Medium => 7, + BlurQuality::High => 12, + }; + let stride = ((ri + target - 1) / target).max(1); + let offs: Vec<(i32, i32)> = (-ri..=ri) + .step_by(stride as usize) + .flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy))) + .filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5) + .collect(); + let white = native(ctx, [1.0, 1.0, 1.0, 1.0]); + let black = native(ctx, [0.0, 0.0, 0.0, 1.0]); + let mut res = src.crop(out); + let mut q = [0.0f32; MAXC]; + let mut acc = [0.0f32; MAXC]; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = xy(out, i); + let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 }); + let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t); + if mode != SmartBlurMode::Normal { + let mut edge = false; + for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] { + for (c, v) in q.iter_mut().enumerate().take(n) { + *v = src.get(x + dx, y + dy, c); + } + if differs(&q[..n]) { + edge = true; + break; + } + } + let a = if ctx.alpha { px[n - 1] } else { 1.0 }; + if edge { + px[..cc].copy_from_slice(&white[..cc]); + } else if mode == SmartBlurMode::EdgeOnly { + px[..cc].copy_from_slice(&black[..cc]); + } + if ctx.alpha { + px[n - 1] = a; + } + continue; + } + acc[..n].fill(0.0); + let mut wsum = 0.0f32; + for &(dx, dy) in &offs { + for (c, v) in q.iter_mut().enumerate().take(n) { + *v = src.get(x + dx, y + dy, c); + } + if differs(&q[..n]) { + continue; + } + let a = if ctx.alpha { q[n - 1] } else { 1.0 }; + for c in 0..n { + acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] }; + } + wsum += 1.0; + } + if wsum > 0.0 { + for c in 0..n { + px[c] = acc[c] / wsum; + } + unpremul_px(px, ctx.alpha); + } + } + res +} + +/// Flat blocks with small noise, so some neighbours fall within the threshold and some don't. +fn image(rect: Rect, n: usize, alpha: bool) -> Image { + let w = rect.width() as usize; + let data = (0..w * rect.height() as usize) + .flat_map(|i| { + let (x, y) = (i % w, i / w); + let block = ((x / 5 + y / 7) % 3) as f32 * 0.3; + (0..n).map(move |c| { + let noise = ((i * 31 + c * 13) % 17) as f32 / 160.0; + if alpha && c + 1 == n { [1.0, 0.0, 0.4, 0.75][(x + y) % 4] } else { block + noise } + }) + }) + .collect(); + Image { rect, ch: n, data } +} + +fn exact(a: &[f32], b: &[f32], what: &str) { + assert_eq!(a.len(), b.len(), "{what}"); + for (i, (a, b)) in a.iter().zip(b).enumerate() { + assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{what}: sample {i}: {a} != {b}"); + } +} + +#[test] +fn offset_fast_path_matches_the_per_channel_oracle() { + let mut layouts: Vec<(ColorMode, usize, bool)> = vec![ + (ColorMode::Rgb, 3, false), + (ColorMode::Rgb, 4, true), + (ColorMode::Grayscale, 1, false), + (ColorMode::Grayscale, 2, true), + (ColorMode::Cmyk, 4, false), + (ColorMode::Cmyk, 5, true), + (ColorMode::Lab, 4, true), + ]; + layouts.extend((1..=MAXC).flat_map(|n| [(ColorMode::Multichannel, n, false), (ColorMode::Multichannel, n, true)])); + let win = Rect::new(-7, -4, 33, 30); + for (mode, n, alpha) in layouts { + let src = image(win, n, alpha); + let ctx = Ctx { bounds: win, mode, alpha }; + for radius in [0.1f32, 1.0, 3.0, 12.0, 17.5] { + let ri = f32::ceil(radius) as i32; + // The usual tile (the window less the halo), the whole window, and one sticking out of it. + let inner = Rect::new(win.x0 + ri, win.y0 + ri, win.x1 - ri, win.y1 - ri); + for out in [inner, win, Rect::new(-12, -9, 10, 8), Rect::EMPTY] { + for quality in [BlurQuality::Low, BlurQuality::Medium, BlurQuality::High] { + for sm in [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge] { + for threshold in [8.0, 60.0] { + let what = format!("{mode:?} n={n} alpha={alpha} r={radius} {out:?} {quality:?} {sm:?} t={threshold}"); + exact( + &smart_blur(&src, out, &ctx, radius, threshold, quality, sm), + &smart_blur_oracle(&src, out, &ctx, radius, threshold, quality, sm), + &what, + ); + } + } + } + } + } + } +} + +#[test] +fn out_of_window_neighbours_read_as_zero() { + // A one-pixel window: every neighbour is outside and reads as transparent black, so a black + // centre averages with all of them and a white one keeps itself. + let ctx = Ctx { bounds: Rect::new(0, 0, 1, 1), mode: ColorMode::Rgb, alpha: true }; + for v in [0.0, 1.0] { + let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 4, data: vec![v, v, v, 1.0] }; + let got = smart_blur(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal); + exact(&got, &smart_blur_oracle(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal), "1 px"); + } +} + +#[test] +fn nan_and_infinite_samples_match_the_oracle() { + // A NaN difference is never over the threshold, so it is averaged in; an infinite one is. + let win = Rect::new(0, 0, 24, 20); + for (n, alpha) in [(3, false), (4, true)] { + let mut src = image(win, n, alpha); + for (i, v) in [(5, f32::NAN), (97, f32::INFINITY), (230, f32::NEG_INFINITY), (311, -0.0)] { + let len = src.data.len(); + src.data[(i * n) % len] = v; + } + let ctx = Ctx { bounds: win, mode: ColorMode::Rgb, alpha }; + let out = Rect::new(4, 4, 20, 16); + for threshold in [8.0, 100.0] { + exact( + &smart_blur(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal), + &smart_blur_oracle(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal), + &format!("n={n} t={threshold}"), + ); + } + } +} diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index aa7ff9bfa1..dbb5b22e65 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -69,6 +69,23 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { h / n.log2() } +/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the +/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the +/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently. +fn ipow(x: f32, k: u32) -> f32 { + let (mut base, mut k, mut acc) = (x, k, 1.0f32); + loop { + if k & 1 != 0 { + acc *= base; + } + k >>= 1; + if k == 0 { + return acc; + } + base *= base; + } +} + fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { let n = v.len() as f32; if v.is_empty() { @@ -97,7 +114,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { _ => {} } let mean = v.iter().sum::() / n; - let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::() / n; + let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::() / n; match s { Stat::Mean => mean, Stat::Variance => m(2), @@ -105,7 +122,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { Stat::Skewness => { let sd = m(2).sqrt(); // Map −2..2 to 0..1 (0.5 = symmetric). - if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) } + if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) } } Stat::Kurtosis => { let var = m(2); @@ -124,6 +141,27 @@ const CHUNK: usize = 4096; /// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float /// range; quantisation/clipping belongs to the destination surface, not the statistic. pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { + // Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so + // the compiler builds one loop per statistic with the `match` folded away. Matching on `s` + // three times per pixel inside one shared loop made one thread 5–15% slower than the old + // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's + // instructions by a fifth. + match s { + Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), + Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), + Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), + Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)), + Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)), + Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)), + Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)), + Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)), + Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)), + Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)), + } +} + +fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> { let len = layers.iter().map(Vec::len).max().unwrap_or(0); let mut out = vec![[0.0f32; 4]; len]; // Every output pixel depends only on the input pixels at the same index, so chunks of the @@ -133,22 +171,24 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| { // Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused // across pixels, so keep that, and give each task its own set. - let mut samples: [Vec; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len())); + // Sized to the layer count up front and filled by index (`k` samples per pixel): a + // `push` per sample re-checked the capacity and stored the length every time; indexing + // cuts the loop's instructions by another third. + let mut samples: [Vec; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]); let mut scratch = EntropyScratch::new(); let base = chunk * CHUNK; for (j, o) in outs.iter_mut().enumerate() { let i = base + j; - for v in samples.iter_mut() { - v.clear(); - } + let mut k = 0; let mut alpha = 0.0f32; for l in layers { if let Some(p) = l.get(i) && p[3] > 0.0 { for c in 0..3 { - samples[c].push(p[c]); + samples[c][k] = p[c]; } + k += 1; alpha = alpha.max(p[3]); } } @@ -156,7 +196,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { continue; } for c in 0..3 { - o[c] = stat(&mut samples[c], s, &mut scratch); + o[c] = f(&mut samples[c][..k], &mut scratch); } o[3] = alpha; } @@ -457,4 +497,16 @@ mod tests { fn no_layers_gives_an_empty_result() { assert!(combine(&[], Stat::Median).is_empty()); } + + /// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power. + #[test] + fn ipow_squares_and_multiplies() { + for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] { + assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits()); + assert_eq!(ipow(x, 1).to_bits(), x.to_bits()); + assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits()); + assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits()); + assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits()); + } + } } diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 31def5f99d..b7fc92113c 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() { assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 }); } +#[test] +fn nonpositive_tile_sizes_match_one_pixel_tiles() { + let area = Rect::new(0, 0, 4, 3); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = pattern(depth, area); + for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) { + let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area)); + for tile in [0, -64, i32::MIN] { + let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area)); + assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE) + .expect("a nonpositive tile size is normalized, not rejected"); + assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none()); + } + } + assert_eq!(source.read_region(area), pattern(depth, area).read_region(area)); + } +} + #[test] fn every_filter_is_tile_independent() { let s = pattern(SampleType::U8, R); @@ -248,6 +269,32 @@ fn minimum_maximum_spread() { assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); } +/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop +/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps +/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones). +#[test] +fn minimum_maximum_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true)); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]); + s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]); + for preserve in [Preserve::Squareness, Preserve::Roundness] { + let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve }); + for x in 17..24 { + assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15)); + } + assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha"); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve }); + for x in 17..24 { + let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 }; + assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15)); + } + assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha"); + } + } +} + #[test] fn maximum_roundness_grows_a_point_into_a_disc() { // One white pixel on black: Maximum spreads it over the structuring element itself. @@ -414,6 +461,24 @@ fn twirl_keeps_centre_and_outside() { assert_ne!(out.pixel(20, 12), s.pixel(20, 12)); } +/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as +/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges +/// on the middle row changes, the corners don't, and a square still uses its inscribed circle. +#[test] +fn round_distortions_fill_the_inscribed_ellipse() { + let b = Rect::new(0, 0, 80, 40); + let s = pattern(SampleType::F32, b); + for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] { + let out = apply(&s, &p, b, b, None); + // 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse. + assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}"); + assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}"); + for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] { + assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse"); + } + } +} + #[test] fn motion_blur_spreads_along_angle_only() { let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index fd35f2742a..7c28485f1f 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() { assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens"); } +#[test] +fn lens_flare_never_darkens_32_bit_values_above_one() { + // #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p); + for px in out.read_region(R).as_chunks::<4>().0 { + assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}"); + } +} + +#[test] +fn lens_flare_8_bit_result_is_the_screen_blend() { + // The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R); + let src = pattern(SampleType::U8, R); + let base = src.read_region(R); + let expected: Vec = base + .as_chunks::<4>() + .0 + .iter() + .zip(amount.as_chunks::<4>().0) + .flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]])) + .collect(); + let mut want = Surface::new(fmt(SampleType::U8)); + want.write_region(R, &expected); + assert_eq!(run(&src, &p).read_region(R), want.read_region(R)); +} + #[test] fn lighting_point_light_falls_off() { let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]); @@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() { assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5); } +/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop +/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched. +/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1). +#[test] +fn custom_offset_brightens_cmyk() { + let mut id = vec![0.0; 25]; + id[12] = 1.0; + let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 }; + for depth in [SampleType::U8, SampleType::U16] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]); + let w = run(&s, &p).pixel(5, 5); + assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]); + let c = run(&s, &p).pixel(5, 5); + let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0]; + assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}"); + } +} + #[test] fn hsb_filter_encodes_and_decodes() { let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]); diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 2632d4a29a..5a0f752fac 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: mask } +/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100) +/// shift, as a fully matched pixel of that colour becomes. +pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let mut px = [[color[0], color[1], color[2], 1.0]]; + replace_color_masked(&mut px, &[1.0], hue, saturation, lightness); + [px[0][0], px[0][1], px[0][2]] +} + +/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as +/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has +/// no hue to turn, black or white no saturation to give) goes as far as they do. +pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) { + let [fh, fs, fl] = rgb_to_hsl(from); + let [th, ts, tl] = rgb_to_hsl(to); + // Hue turns the short way round; a grey target keeps the hue where it is. + let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 }; + // Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0. + let amount = |from: f32, to: f32| { + if to >= from { + if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 } + } else if from <= 0.0 { + 0.0 + } else { + (to / from - 1.0) * 100.0 + } + }; + (hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0)) +} + /// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` /// (`0..=1`, one per pixel: the colour match, localized or not) covers it. pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { @@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { mod tests { use super::*; + /// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets + /// the sliders that give it. + #[test] + fn replace_color_result_and_shift_round_trip() { + let from = [0.8, 0.2, 0.2]; + let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3); + assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change"); + for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] { + let (h, s, l) = replace_color_shift(from, to); + assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l)); + assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l)); + } + // Red to blue turns the short way (−120°, not +240°). + assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5); + // A grey target: saturation all the way down, the hue left alone. + let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]); + assert_eq!((h, s), (0.0, -100.0)); + // Black has no saturation to raise: the slider stays put rather than dividing by zero. + let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]); + assert!(h.is_finite() && s.is_finite() && l > 0.0); + } + fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> { (0..w * h) .map(|i| { diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 050d08c322..3edd0a1879 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -231,21 +231,61 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { /// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners /// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to -/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before +/// admitting a transform: clipping these bounds must not hide an excessive scale. pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + corner_bounds(&warp_corners(src_rect, h)?) +} + +/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`]. +/// Position does not affect the price, so translating a small layer far away stays cheap. +/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse +/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output. +pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> { + let corners = warp_corners(src_rect, h)?; + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) { + return Some((u64::MAX, u64::MAX)); + } + let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor(); + let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor(); + let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil(); + let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil(); + // Float-to-integer casts saturate, including when the finite endpoints' difference overflows. + Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2))) +} + +/// The farthest from the origin (px) [`warp_surface`] places a pixel: each side of +/// [`warp_output_bounds`] is clamped to ±2²⁰. +pub const WARP_REACH: i32 = 1 << 20; + +/// Would [`warp_surface`] clip the result of warping `src_rect` by `h`: does a warped corner land +/// past [`WARP_REACH`], whether the result is that large or only placed that far out? A caller +/// refuses such a warp rather than lose the pixels beyond it. Empty and singular inputs have no +/// output to clip. +pub fn warp_output_clipped(src_rect: Rect, h: &Homography) -> bool { + let Some(corners) = warp_corners(src_rect, h) else { return false }; + let lim = f64::from(WARP_REACH); + let within = |v: f64| v.is_finite() && v.floor() >= -lim && v.ceil() <= lim; + !corners.iter().all(|&(x, y)| within(x) && within(y)) +} + +fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> { if src_rect.is_empty() || h.inverse().is_none() { return None; } - let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] - .map(|(x, y)| h.apply(x as f64, y as f64)); - corner_bounds(&corners) + Some( + [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)), + ) } +/// The pixel rectangle over the warped `corners` plus a pixel for filter support, each side +/// clamped to [`WARP_REACH`]; `None` when a corner is not finite. fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) { return None; } - let lim = (1 << 20) as f64; + let lim = f64::from(WARP_REACH); let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; @@ -271,7 +311,7 @@ fn warp( converted = src.try_convert(fmt)?; &converted }; - let mut out = Surface::new(fmt); + let out = Surface::new(fmt); if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) { return Ok(out); } @@ -310,7 +350,12 @@ fn warp( // A source straddling the horizon draws only its front side: the inverse w at a pixel has the // sign of the forward w at the source point it samples, and the other side would show as a // mirrored copy wrapped round infinity (#4863). - let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])); + // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn + // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557). + let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64))); + let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0))); + let front = + side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]))); let bounds = corner_bounds(&corners); let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. @@ -338,7 +383,7 @@ fn warp( return Ok(None); } let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - // The last few percent are the writes. + // Short of 100 %: the result is still pruned and handed back after the last tile. ctl.progress(0.98 * k as f32 / total as f32); // Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter. let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64)); @@ -467,41 +512,40 @@ fn warp( any = true; let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n; for c in 0..a { - // Integer storage clips on write; float colours retain their signed/HDR range. - outp[o + c] = (acc[c] / al) as f32; + // Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to + // partly transparent pixels, and dividing by the clamped 1 would lighten the + // colour by the overshoot (#6203). Integer storage clips on write; float + // colours retain their signed/HDR range. + outp[o + c] = (acc[c] / acc[a]) as f32; } outp[o + a] = al as f32; } } Ok(any.then_some((*t, outp))) }; - let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; - let mut done = Vec::new(); - done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?; - let done = std::sync::Mutex::new(done); + // Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32` + // pixels: kept until the last one was done, they cost 16 bytes a pixel of an RGBA result on + // top of the result's own (#7807). + let out = std::sync::Mutex::new(out); + let warp_and_write = |tile: &Rect| -> Result<(), photocraft_raster::AllocationError> { + if let Some((r, v)) = work(tile)? { + out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).try_write_region(r, &v)?; + } + Ok(()) + }; #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; - tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> { - if let Some(result) = work(&tile)? { - done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result); - } - Ok(()) - })?; + tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?; } #[cfg(target_arch = "wasm32")] for tile in tiles { - if let Some(result) = work(&tile)? { - done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result); - } + warp_and_write(&tile)?; } - let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if ctl.cancelled() { - return Ok(out); - } - for (r, v) in done { - out.try_write_region(r, &v)?; + return Ok(Surface::new(fmt)); } + let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); out.prune(); Ok(out) } @@ -510,6 +554,47 @@ fn warp( mod tests { use super::*; + #[test] + fn output_size_prices_the_unclipped_extent_independently_of_position() { + let src = Rect::new(0, 0, 24, 16); + for dx in [0.0, 900_000.0, 10_000_000.0] { + let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((26, 18))); + } + for sx in [1e7, -1e7] { + let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18))); + } + } + + /// The unclipped size matches the clipped bounds where nothing is clipped, saturates when it + /// can't be represented, and is `None` for empty or singular inputs. + #[test] + fn output_size_saturates_overflows_and_preserves_filter_support() { + let src = Rect::new(0, 0, 24, 16); + let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]); + let bounds = warp_output_bounds(src, &fractional).unwrap(); + assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height())))); + let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX))); + assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None); + assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None); + } + + /// [`warp_output_clipped`] says yes exactly when [`warp_output_bounds`] clamps a side (#7807). + #[test] + fn output_clipped_matches_the_clamped_bounds() { + let src = Rect::new(0, 0, 24, 16); + let at = |dx: f64| Homography([1.0, 0.0, dx, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let edge = f64::from(WARP_REACH) - 24.0; + assert!(!warp_output_clipped(src, &at(edge)), "the right edge lands on the reach"); + assert_eq!(warp_output_bounds(src, &at(edge)).map(|b| b.x1), Some(WARP_REACH + 1)); + assert!(warp_output_clipped(src, &at(edge + 1.0)), "one pixel past it"); + assert_eq!(warp_output_bounds(src, &at(edge + 1.0)).map(|b| b.x1), Some(WARP_REACH + 1), "clamped"); + assert!(warp_output_clipped(src, &at(-f64::from(WARP_REACH) - 1.0))); + assert!(!warp_output_clipped(Rect::EMPTY, &at(1e12)), "nothing to clip"); + } + #[test] fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { let mut s = Surface::new(PixelFormat::RGBA8); @@ -665,6 +750,22 @@ mod tests { assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum()); } + /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across + /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the + /// quad, so its side is the one drawn, not the source's majority. + #[test] + fn a_clip_wholly_on_one_side_draws_that_side() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]]; + let h = quad_to_rect(&q, 50.0, 39.0).unwrap(); + assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side"); + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap(); + assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); @@ -739,6 +840,32 @@ mod tests { assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}"); } + /// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots + /// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a + /// light border. + #[test] + fn rotation_keeps_the_colour_of_a_soft_edged_sprite() { + let mut s = rgba(); + s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0); + let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]]; + let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap(); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap(); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}"); + } + } + } + } + } + #[test] fn large_downscale_is_prefiltered() { // A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white. diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 77b7bb107c..1cf39b23ec 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -345,21 +345,41 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let src_copy = surf.clone(); let fmt = surf.format(); let n = fmt.channels(); + // Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result. + let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]); let mut dabs = 0; for p in points { let mc = metric(&hd_inv, lud, lvd, *p); // Image-space footprint: map a metric circle's bounding points. - let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; - for k in 0..16 { - let a = k as f64 / 16.0 * std::f64::consts::TAU; - let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd); - let (x, y) = hd.apply(u, v); - bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; - } - let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)); + let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| { + let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for k in 0..16 { + let a = k as f64 / 16.0 * std::f64::consts::TAU; + let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv); + bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; + } + Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)) + }; + let area = footprint(&hd, lud, lvd, mc); if area.is_empty() || area.width() > 4096 || area.height() > 4096 { continue; } + // The source footprint, read once; `sample` reads any pixel outside it from the surface. + // Skipped when it dwarfs the destination (cloning from near to far in strong perspective): + // decoding it would cost more than the few per-pixel reads it saves. + win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2); + let dest_px = u64::from(area.width()) * u64::from(area.height()); + if win.area.is_empty() + || win.area.width() > 4096 + || win.area.height() > 4096 + || u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px + { + win.area = Rect::new(0, 0, 0, 0); + win.data.clear(); + } else { + src_copy.read_region_into(win.area, &mut win.data); + } let mut data = surf.read_region(area); let w = area.width() as usize; let mut changed = false; @@ -376,7 +396,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: // Source pixel at the same metric offset. let sm = [m[0] + offset[0], m[1] + offset[1]]; let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs); - let s = sample(&src_copy, sx - 0.5, sy - 0.5, n); + sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); for c in 0..n { px[c] += (s[c] - px[c]) * k; } @@ -390,11 +410,42 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: dabs } -fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec { +/// Pixels of `area` read from the source surface (`read_region` layout). +struct Window { + area: Rect, + data: Vec, +} + +/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four +/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region +/// read from `s`. +fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec, out: &mut [f32]) { + let n = out.len(); let (x0, y0) = (x.floor() as i32, y.floor() as i32); let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); - let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2)); - (0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect() + let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1)); + let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1)); + if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 { + // Outside the window (or none): one region read looks the tile up once, where four + // `read_pixel` calls would look it up four times. Same corner order as below. + s.read_region_into(foot, corners); + } else { + corners.resize(4 * n, 0.0); + let w = win.area.width() as usize; + for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() { + let dst = &mut corners[k * n..(k + 1) * n]; + if win.area.contains(px, py) { + let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n; + dst.copy_from_slice(&win.data[o..o + n]); + } else { + s.read_pixel(px, py, dst); + } + } + } + let r = corners; + for (c, o) in out.iter_mut().enumerate() { + *o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty; + } } #[cfg(test)] @@ -487,4 +538,47 @@ mod tests { assert_eq!(n, 1); assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32)); } + + #[test] + fn sample_at_the_edge_of_i32_does_not_overflow() { + let s = Surface::new(PixelFormat::RGBA8); + let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + let (mut corners, mut out) = (Vec::new(), [1.0; 4]); + sample(&s, &win, 3e9, 3e9, &mut corners, &mut out); + assert_eq!(out, [0.0; 4]); + } + + #[test] + fn sample_interpolates_across_tile_edges() { + let mut s = Surface::new(PixelFormat::RGBA8); + let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]); + // Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge. + s.fill_rect(Rect::new(0, 0, 256, 512), &a); + s.fill_rect(Rect::new(256, 0, 512, 512), &b); + let (mut corners, mut out) = (Vec::new(), [0.0; 4]); + let want = [0.75, 0.0, 0.25, 1.0]; + // From the surface, from a window holding the footprint, and from one holding half of it. + for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] { + let mut win = Window { area, data: Vec::new() }; + s.read_region_into(area, &mut win.data); + sample(&s, &win, 255.25, 255.5, &mut corners, &mut out); + assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}"); + } + // Four different corners, one per tile, so both axes of the corner order count. + let mut q = Surface::new(PixelFormat::RGBA8); + for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] { + q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px); + } + let want = [0.375, 0.25, 0.75, 1.0]; + for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] { + let mut win = Window { area, data: Vec::new() }; + q.read_region_into(area, &mut win.data); + sample(&q, &win, 255.25, 255.75, &mut corners, &mut out); + assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}"); + } + // Outside every tile, the default (transparent) pixel. + let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; + sample(&s, &win, -10.5, -10.5, &mut corners, &mut out); + assert_eq!(out, [0.0; 4]); + } } diff --git a/crates/algo/src/video.rs b/crates/algo/src/video.rs index bbd093b0a8..512768fb7a 100644 --- a/crates/algo/src/video.rs +++ b/crates/algo/src/video.rs @@ -88,3 +88,162 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec { } res } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FilterParams, apply}; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn tolerance(depth: SampleType) -> f32 { + match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + } + } + + fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for (row, px) in pixels.iter().enumerate() { + let y = bounds.y0 + row as i32; + src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px); + } + src + } + + #[test] + fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() { + // Odd document origin: parity is relative to the bounds, not absolute y. + let b = Rect::new(-2, 11, 0, 16); + let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]); + for depth in DEPTHS { + let src = rows(depth, b, &pixels); + let before = src.read_region(b); + for (eliminate_even, interpolate, neighbours) in [ + (false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]), + (false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]), + (true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]), + (true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]), + ] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + for (row, (a, c)) in neighbours.into_iter().enumerate() { + for x in b.x0..b.x1 { + let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c)); + let actual = out.pixel(x, b.y0 + row as i32); + for channel in 0..4 { + let expected = (pa[channel] + pc[channel]) / 2.0; + assert!( + (actual[channel] - expected).abs() <= tolerance(depth), + "{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}" + ); + } + } + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn deinterlace_interpolation_weights_colour_by_alpha() { + let b = Rect::new(0, 0, 1, 3); + for depth in DEPTHS { + for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] { + let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]); + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None); + let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]); + let total = a + c; + let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] }; + let actual = out.pixel(0, 1); + for (v, want) in actual.iter().zip(expected) { + assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}"); + } + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 2), src.pixel(0, 2)); + } + } + } + + #[test] + fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() { + for depth in DEPTHS { + for height in [1, 2] { + let b = Rect::new(0, -3, 1, -3 + height); + let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + src.fill_rect(b, &[0.2]); + if height == 2 { + src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]); + } + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 }; + for y in b.y0..b.y1 { + assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}"); + } + } + } + } + } + } + + #[test] + fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() { + let area = Rect::new(0, 0, 1, 5); + let b = Rect::new(0, 1, 1, 4); + let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]); + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None); + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 4), src.pixel(0, 4)); + } + } + } + + #[test] + fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() { + let b = Rect::new(0, 0, 1, 5); + for depth in DEPTHS { + let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}"); + } + } + + fn luma_chroma(p: &[f32]) -> (f32, f32) { + let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; + let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2]; + let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2]; + (y, i.hypot(q)) + } + + #[test] + fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() { + // Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range. + let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5); + let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]]; + let b = Rect::new(0, 0, 1, colours.len() as i32); + for depth in DEPTHS { + let src = rows(depth, b, &colours); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + // The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step. + let eps = 0.0003 + 2.0 * tolerance(depth); + for y in b.y0..b.y1 { + let (before, after) = (src.pixel(0, y), out.pixel(0, y)); + let (y0, c0) = luma_chroma(&before); + let (y1, c1) = luma_chroma(&after); + assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction"); + assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}"); + assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}"); + assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}"); + assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0)); + assert_eq!(after[3], before[3], "alpha changed: {depth:?}"); + } + } + } +} diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 188de07d47..dd20daf6c9 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -14,8 +14,9 @@ //! later (lower-right) cell wins. Work is split per destination tile (parallel on native). use photocraft_color::PixelFormat; -use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_geom::{Rect, TileCoord}; +use photocraft_raster::{Interrupt, Surface, Tile}; +use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -74,6 +75,16 @@ pub fn place_source_projective( /// coordinates → destination document coordinates). Output has `src`'s format with alpha /// added; pixels outside the warped area are transparent. pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface { + mesh(src, src_rect, f, interp, &Interrupt::NONE) +} + +/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled. +pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option { + let out = mesh(src, src_rect, f, interp, ctl); + (!ctl.cancelled()).then_some(out) +} + +fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface { if src_rect.is_empty() { let fmt = src.format(); return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); @@ -103,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> tris.push([a, a + w1 + 1, a + w1]); } } - warp_triangles(src, src_rect, &verts, &tris, interp) + triangles(src, src_rect, &verts, &tris, interp, ctl) } /// Probe lines per axis [`mapped_lengths`] follows through the map. @@ -148,6 +159,12 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) - /// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the /// order is the depth order). Output has `src`'s format with alpha added. pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface { + triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE) +} + +/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips +/// the remaining tiles; the caller discards the result. +fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface { let mut fmt = src.format(); let converted; let src = if fmt.alpha { @@ -207,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let n = fmt.channels(); let a = n - 1; - let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec)> { + // Tiles finished so far, for progress reported per tile (from any worker thread). + let finished = std::sync::atomic::AtomicUsize::new(0); + let total = tiles.len().max(1); + let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> { + if ctl.cancelled() { + return None; + } + let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + // The last few percent are putting the tiles in place. + ctl.progress(0.98 * k as f32 / total as f32); if tris.is_empty() { return None; } @@ -296,6 +322,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) } }; + // Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so + // the samplers can index `px` directly instead of bounds-checking every tap. + let base = |x: i32, y: i32, taps: i32| -> Option { + (x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n) + }; let mut outp = vec![0.0f32; w * h * n]; let mut any = false; let mut acc = [0.0f64; 8]; @@ -313,6 +344,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 Interp::Bilinear => { let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (fx, fy) = (u - f64::from(ix), v - f64::from(iy)); + if let Some(o) = base(ix, iy, 2) { + for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { + for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { + let p = &px[o + (dy * fw + dx) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * wx * wy; + } + } + } + return; + } for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { @@ -327,6 +369,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let (u, v) = (snap(u), snap(v)); let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy))); + if let Some(o) = base(ix - 1, iy - 1, 4) { + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let k = wxi * wyj; + if k == 0.0 { + continue; + } + let p = &px[o + (j * fw + i) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * k; + } + } + } + return; + } for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; @@ -374,23 +431,35 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 any = true; let b = o * n; for c in 0..a { - outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32; + // The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203). + outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32; } outp[b + a] = al as f32; } - any.then_some((*t, outp)) + if !any { + return None; + } + // Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at + // a time once all are done. `t` lies in one tile. + let mut one = Surface::new(fmt); + one.write_region(*t, &outp); + one.prune(); + if one.tile_count() == 0 { + return None; + } + one.take_tiles(*t).pop() }; #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { + let done: Vec<(TileCoord, Arc)> = { use rayon::prelude::*; tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect() }; #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); + let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + if ctl.cancelled() { + return out; } - out.prune(); + out.put_tiles(done); out } @@ -463,6 +532,28 @@ mod tests { assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20)); } + #[test] + fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]); + let r = s.content_bounds(); + let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]); + let map = |x: f64, y: f64| w.map(x, y); + let reported = std::sync::Mutex::new(0.0f32); + let progress = |p: f32| { + let mut m = reported.lock().unwrap(); + *m = m.max(p); + }; + let never = || false; + let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled"); + let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic); + assert_eq!(out.content_bounds(), want.content_bounds()); + assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds())); + assert!(*reported.lock().unwrap() > 0.9); + let always = || true; + assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none()); + } + #[test] fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() { // #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about @@ -498,6 +589,28 @@ mod tests { } } + #[test] + fn a_rotated_soft_edged_sprite_keeps_its_colour() { + // #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half + // transparent edge, and must not lighten the colour. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let k = std::f64::consts::FRAC_1_SQRT_2; + let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k); + let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}"); + } + } + } + } + #[test] fn translation_and_known_arc_points() { let src = sample(SampleType::U8); diff --git a/crates/algo/tests/it/liquify_source_footprint.rs b/crates/algo/tests/it/liquify_source_footprint.rs new file mode 100644 index 0000000000..759567827f --- /dev/null +++ b/crates/algo/tests/it/liquify_source_footprint.rs @@ -0,0 +1,108 @@ +//! Liquify source reads must depend on the mapping, not the output tile phase. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Keeps the document size fixed while moving its edges relative to global tiles. +fn bounds(shift: i32) -> Rect { + Rect::new(shift, 0, shift + 1024, 512) +} + +/// Builds translated copies of RGB ramps and alternating columns so missing +/// interpolation taps change the sampled colors. +fn source(sample: SampleType, shift: i32) -> Surface { + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false)); + let mut row = vec![0.0; 1024 * 3]; + for y in 0..512 { + for (x, pixel) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + *pixel = [x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0]; + } + surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row); + } + surface +} + +/// Creates matching brush paths in document-relative coordinates, optionally +/// beside a frozen region that gives the displacement field a sharp boundary. +fn strokes(shift: i32, masked: bool) -> Vec { + let offset = f64::from(shift); + let mut strokes = Vec::new(); + if masked { + let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + freeze.amount = Some(1.0); + freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]]; + strokes.push(freeze); + } + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0); + warp.density = 100.0; + warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]]; + strokes.extend(std::iter::repeat_n(warp, 8)); + for stroke in &strokes { + stroke.validate().expect("the fixture uses supported brush parameters"); + } + strokes +} + +/// Checks that a half-tile translation preserves editable output across sample +/// depths and mesh sizes when image content and brush paths move together. +fn check_tile_translation(masked: bool) { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let translated = source(sample, 128); + for cell in [2.0, 4.0] { + let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked)); + let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked)); + if masked { + assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values"); + } + // The reported pixels lie entirely on the editable side of the mask. + assert_eq!(field.freeze_at(509.5, 220.5), 0.0); + assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0); + let output = apply_liquify(&original, &field); + let shifted_output = apply_liquify(&translated, &shifted_field); + assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip"); + let mut worst = 0.0f32; + // Translation changes which output tile contains this strip, while + // preserving the relative source image, brush path and document edges. + for y in 220..290 { + for x in 509..512 { + let a = output.pixel(x, y); + let b = shifted_output.pixel(x + 128, y); + for (a, b) in a.iter().zip(&b) { + worst = worst.max((a - b).abs()); + } + } + } + // Coordinate translation can perturb the last few F32 arithmetic bits. + // This is far smaller than the clipped source-stencil error. + let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 }; + assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}"); + } + } +} + +#[test] +fn cubic_source_footprint_is_independent_of_output_tile_phase() { + check_tile_translation(true); +} + +#[test] +fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() { + check_tile_translation(false); +} + +#[test] +fn source_reads_still_clamp_at_the_document_edge() { + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0); + warp.density = 100.0; + warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]]; + warp.validate().expect("the edge fixture uses supported brush parameters"); + let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]); + assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge"); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let output = apply_liquify(&original, &field); + assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge"); + } +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs new file mode 100644 index 0000000000..107626cfc2 --- /dev/null +++ b/crates/algo/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod liquify_source_footprint; +mod retouch_blur_exact; +mod warp_clipped; diff --git a/crates/algo/tests/retouch_blur_exact.rs b/crates/algo/tests/it/retouch_blur_exact.rs similarity index 100% rename from crates/algo/tests/retouch_blur_exact.rs rename to crates/algo/tests/it/retouch_blur_exact.rs diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/it/warp_clipped.rs similarity index 100% rename from crates/algo/tests/warp_clipped.rs rename to crates/algo/tests/it/warp_clipped.rs diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index fb718e5668..ae1837cdd3 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -10,7 +10,6 @@ rust-version.workspace = true workspace = true [dependencies] -photocraft-geom = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } @@ -28,5 +27,11 @@ getrandom = { workspace = true } cap-std = "4" [dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } + +# Reading back an extended attribute the write path should have preserved (#7095). +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index cd34ab3410..fc4526eedb 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -19,7 +19,8 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { - return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let r = photocraft_io::import(name, bytes)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) @@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); + 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(); diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index d6b3c17102..1e3e83e1bf 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -72,6 +72,22 @@ impl Headless { } } + /// Run `body` with document `index` active (the active one when `None`), then make the + /// previously active document active again, on success and on error alike: an `index` + /// targets one call and must not retarget the next automation command (#7025). + fn on_doc(&mut self, index: Option, body: impl FnOnce(&mut Self) -> Result) -> Result { + let i = self.doc_index(index)?; + let previously_active = self.session.active_index(); + if previously_active != Some(i) && !self.session.set_active(i) { + return Err(AutomationError::BadRequest(format!("no document at index {i}"))); + } + let result = body(self); + if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) { + self.session.set_active(active); + } + result + } + /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; @@ -84,6 +100,8 @@ impl Headless { files::open_bytes(name, &bytes)? } }; + // Preferences › Raw Defaults › Resolution, as the app's File › Open applies it. + file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document); let path = match file_cmds::template_name(&self.session, requested) { Some(untitled) => { o.document.name = untitled; @@ -113,27 +131,25 @@ impl Headless { /// automation (#2810). `path` resolves beneath the read root the way `open` resolves one, /// and `params` carries the command's own options (`scale`, `fit`, `center`). pub fn place(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - // Where ambient paths are allowed the command reads the file itself, as everywhere else. - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - return self.command_run("file.placeEmbedded", Value::Object(opts)); - } - let bytes = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace.read(path)?, - _ => { - return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + self.on_doc(index, |this| { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + // Where ambient paths are allowed the command reads the file itself, as everywhere else. + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + return this.command_run("file.placeEmbedded", Value::Object(opts)); } - }; - let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); - self.sync_jobs(); - // The bytes are already in memory and the path resolved beneath the root capability, so - // placing them needs no filesystem check of its own (`place_bytes` is the command body). - Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?) + let bytes = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace.read(path)?, + _ => { + return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + } + }; + let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); + this.sync_jobs(); + // The bytes are already in memory and the path resolved beneath the root capability, so + // placing them needs no filesystem check of its own (`place_bytes` is the command body). + Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?) + }) } /// Export each exported layer of a document as its own file beneath the write root: the @@ -143,25 +159,23 @@ impl Headless { /// already exists, where the ambient command would, and it creates the directory before the /// export runs, so a refused export can leave an empty directory behind — never a file. pub fn export_layers(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.layersToFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.layersToFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Export each data set of a document as its own flattened file beneath the write root: the @@ -169,25 +183,23 @@ impl Headless { /// batch is planned first and colliding names are refused before any file is written; the /// directory itself is created before that, so a refused batch can leave it empty behind. pub fn export_data_sets(&mut self, index: Option, dir: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("dir".into(), json!(dir)); - return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); - } - self.sync_jobs(); - let workspace = match &self.filesystem { - Filesystem::Workspace(workspace) => workspace, - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), - }; - let mut outputs = workspace.export_sequence(dir)?; - let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| { - outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) - }); - exported(dir, written, &mut outputs) + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("dir".into(), json!(dir)); + return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts)); + } + this.sync_jobs(); + let workspace = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace, + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), + }; + let mut outputs = workspace.export_sequence(dir)?; + let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| { + outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) + }); + exported(dir, written, &mut outputs) + }) } /// Import a data set CSV from the read root: the capability-scoped @@ -195,24 +207,22 @@ impl Headless { /// in automation (#3216). The file is read beneath the root the way `open` reads one and /// handed to the command's own parser over the text it already has. pub fn import_data_sets(&mut self, index: Option, path: &str, params: &Value) -> Result { - let i = self.doc_index(index)?; - if self.session.active_index() != Some(i) && !self.session.set_active(i) { - return Err(AutomationError::BadRequest(format!("no document at index {i}"))); - } - if matches!(&self.filesystem, Filesystem::TrustedLocal) { - let mut opts = params.as_object().cloned().unwrap_or_default(); - opts.insert("path".into(), json!(path)); - return self.command_run("file.import.variableDataSets", Value::Object(opts)); - } - let text = match &self.filesystem { - Filesystem::Workspace(workspace) => { - let bytes = workspace.read(path)?; - String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + self.on_doc(index, |this| { + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + return this.command_run("file.import.variableDataSets", Value::Object(opts)); } - _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), - }; - self.sync_jobs(); - Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?) + let text = match &this.filesystem { + Filesystem::Workspace(workspace) => { + let bytes = workspace.read(path)?; + String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))? + } + _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), + }; + this.sync_jobs(); + Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?) + }) } /// Save (`.pcraft` or any export format by extension). With no path, @@ -266,14 +276,12 @@ impl Headless { format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) { // Saving by index must not retarget the next automation command. - let previously_active = self.session.active_index(); - self.session.set_active(i); - if let Some(st) = self.session.active_mut() { - st.saved_to(target.to_string_lossy().into_owned()); - } - if let Some(active) = previously_active { - self.session.set_active(active); - } + self.on_doc(Some(i), |this| { + if let Some(st) = this.session.active_mut() { + st.saved_to(target.to_string_lossy().into_owned()); + } + Ok(()) + })?; } Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings })) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 15a0e24e30..c0369c917d 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -114,10 +114,20 @@ impl Headless { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; - if let Some(q) = p.get("quality").and_then(Value::as_u64) { - opts.encode.jpeg_quality = q.clamp(1, 100) as u8; - opts.encode.webp_quality = q.clamp(1, 100) as u8; - opts.encode.webp_lossless = false; + opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default(); + match p.get("quality") { + None | Some(Value::Null) => {} + Some(v) => { + // Like the CLI's `--quality`: never ignore or clamp a bad value. + let q = v + .as_u64() + .filter(|q| (1..=100).contains(q)) + .and_then(|q| u8::try_from(q).ok()) + .ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?; + opts.encode.jpeg_quality = q; + opts.encode.webp_quality = q; + opts.encode.webp_lossless = false; + } } let path = str_of(&p, "path").map(PathBuf::from); self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts) @@ -341,6 +351,19 @@ mod tests { Mutex::new(Headless::trusted_local()) } + #[test] + fn document_activation_reply_survives_engine_rpc() { + let h = session(); + let mut g = h.lock().unwrap(); + for _ in 0..2 { + g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap(); + } + let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap(); + assert_eq!(reply, json!({"document": 0})); + let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap(); + assert_eq!(reply, json!({"document": 1})); + } + #[test] fn execute_reports_invalid_filter_parameters_without_changing_history() { let h = session(); @@ -576,6 +599,26 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was + /// clamped, where the CLI refuses anything but a whole number from 1 to 100. + #[test] + fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() { + let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("x.webp"); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap(); + for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] { + let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string(); + assert!(err.contains("quality"), "{q}: {err}"); + assert!(!path.exists(), "{q} wrote a file"); + } + h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy"); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() { let mut h = Headless::new(); @@ -756,6 +799,43 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn indexed_place_and_data_set_methods_keep_the_active_document() { + let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap(); + std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").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", "name": "first"})).unwrap(); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap(); + let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id"); + h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}})) + .unwrap(); + h.handle("doc.select", json!({"index": 0})).unwrap(); + + let steps = [ + ("doc.place", json!({"path": "logo.png", "index": 1}), true), + ("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true), + ("doc.exportLayers", json!({"dir": "layers", "index": 1}), true), + ("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true), + // A failing call restores the active document too. + ("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false), + ]; + for (method, params, succeeds) in steps { + let result = h.handle(method, params.clone()); + assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}"); + assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1"); + } + assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document"); + assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1"); + assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn trusted_local_render_keeps_full_size_behavior() { let mut h = Headless::trusted_local(); diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 6198f22851..0735511f20 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> { } /// Compare fixed-width tokens without an early exit on a mismatching byte. +/// +/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps +/// the token lowercased, so a client sending the token exactly as it was configured must still +/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit. pub fn token_matches(expected: &str, supplied: &str) -> bool { if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN { return false; } - expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0 + expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0 } /// Validate the first TCP frame without exposing any control method before authentication. @@ -368,6 +372,45 @@ mod tests { assert_eq!(reply["result"]["authenticated"], true); } + fn auth_line(token: &str) -> String { + json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string() + } + + /// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a + /// client sending the token exactly as it was configured was refused. + #[test] + fn a_token_authenticates_in_any_letter_case() { + let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef"; + let expected = server_token(Some(configured), None).unwrap(); + for supplied in [configured, &configured.to_ascii_lowercase(), mixed] { + let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected); + assert!(authenticated, "{supplied}: {reply}"); + assert_eq!(reply["result"]["authenticated"], true); + } + // The comparison is case-insensitive in both directions. + assert!(token_matches(configured, &configured.to_ascii_lowercase())); + + // A different token, or a non-hex character that only folds to a hex one, still fails. + let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let (_, authenticated) = authentication_reply(&auth_line(other), &expected); + assert!(!authenticated); + let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + assert!(!token_matches(&expected, not_hex)); + } + + /// #6425: the same for a token file whose contents are uppercase. + #[test] + fn an_uppercase_token_file_authenticates_its_exact_contents() { + let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); + let contents = generate_token().unwrap().to_ascii_uppercase(); + create_token_file(&path, &contents).unwrap(); + let expected = server_token(None, Some(&path)); + std::fs::remove_file(&path).unwrap(); + let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); + assert!(authenticated, "{reply}"); + } + #[test] fn repeat_auth_on_an_authenticated_connection_says_so() { let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string(); @@ -437,16 +480,23 @@ mod tests { #[test] fn an_authenticated_connection_outlives_the_authentication_deadline() { let (server, mut client) = tcp_pair(); + // The test thread writes the first line before the deadline starts. The sender writes the + // second line once the connection has authenticated and the deadline has passed. + client.write_all(b"one\n").unwrap(); + let limit = Duration::from_secs(1); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit)); + let deadline = Instant::now() + limit; + let (tx, rx) = std::sync::mpsc::channel(); let sender = std::thread::spawn(move || { - client.write_all(b"one\n").unwrap(); - std::thread::sleep(Duration::from_millis(500)); + rx.recv().unwrap(); + std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now())); client.write_all(b"two\n").unwrap(); }); - let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); let mut line = String::new(); assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "one\n"); reader.get_mut().authenticated().unwrap(); + tx.send(()).unwrap(); // Sent 300 ms after the deadline: only the idle timeout applies now. assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "two\n"); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f52376a9c5..46905daf38 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -170,6 +170,13 @@ pub struct SaveParams { /// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16. #[serde(default, rename = "dpxBits")] pub dpx_bits: Option, + /// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or + /// "bc3" (DXT5). + #[serde(default, rename = "ddsCompression")] + pub dds_compression: Option, + /// DDS: also write the full mip chain, each level a box filter of the one above. + #[serde(default, rename = "ddsMipmaps")] + pub dds_mipmaps: bool, #[serde(default)] pub index: Option, } @@ -1001,10 +1008,18 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), }; + if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) { + return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}"))); + } + let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) { + None => None, + Some((_, Some(c))) => Some(c), + Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))), + }; if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does) and the Targa bits, so the other options are headless-only. Saying so beats + // does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats // saving with defaults while the caller believes their quality or format was applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), @@ -1026,15 +1041,22 @@ impl PhotocraftMcp { if let Some(bits) = tga_bits { params["tgaBits"] = json!(bits.bits()); } + if let Some(c) = dds_compression { + params["ddsCompression"] = json!(c.name()); + } + if p.dds_mipmaps { + params["ddsMipmaps"] = json!(true); + } return to_result(b.call("app.save", params).await); } let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; opts.encode.dpx_bits = dpx_bits; + opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; if let Some(q) = p.quality { - opts.encode.jpeg_quality = q.clamp(1, 100); - opts.encode.webp_quality = q.clamp(1, 100); + opts.encode.jpeg_quality = q; + opts.encode.webp_quality = q; opts.encode.webp_lossless = false; } let path = p.path.map(PathBuf::from); @@ -1175,7 +1197,7 @@ mod tests { break; } assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}"); - std::thread::sleep(std::time::Duration::from_millis(5)); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; } } } diff --git a/crates/automation/src/server/transport.rs b/crates/automation/src/server/transport.rs index 44ba4596ba..82e96b13c3 100644 --- a/crates/automation/src/server/transport.rs +++ b/crates/automation/src/server/transport.rs @@ -7,7 +7,7 @@ use crate::security::MAX_REQUEST_BYTES; /// Read a complete request before allowing any of its bytes to reach rmcp. `lines()` /// buffers without a limit, while forwarding partial input can make a valid JSON prefix /// executable at EOF after an oversized suffix is rejected. -async fn bounded_line(source: &mut BufReader, maximum: usize) -> std::io::Result> { +async fn bounded_line(source: &mut BufReader, maximum: usize) -> std::io::Result>> { let mut bytes = Vec::new(); loop { let available = source.fill_buf().await?; @@ -15,7 +15,7 @@ async fn bounded_line(source: &mut BufReader if bytes.is_empty() { return Ok(None); } - return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + return Ok(Some(bytes)); } let newline = available.iter().position(|&byte| byte == b'\n'); let length = newline.map_or(available.len(), |at| at + 1); @@ -29,7 +29,7 @@ async fn bounded_line(source: &mut BufReader if bytes.last() == Some(&b'\r') { bytes.pop(); } - return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + return Ok(Some(bytes)); } } } @@ -48,11 +48,16 @@ impl PhotocraftMcp { let reader = tokio::spawn(async move { let mut lines = BufReader::new(input); while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? { - if line.trim().is_empty() { + if std::str::from_utf8(&line).is_ok_and(|text| text.trim().is_empty()) { continue; } - match serde_json::from_str::(&line) { - Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?, + // Bytes that are not UTF-8 are malformed JSON too: they get the -32700 reply + // like any other bad line instead of ending the session. + match serde_json::from_slice::(&line) { + Ok(_) => { + to_service.write_all(&line).await?; + to_service.write_all(b"\n").await?; + } Err(e) => { let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}}); if errors.send(reply.to_string()).await.is_err() { @@ -99,8 +104,8 @@ mod tests { #[tokio::test] async fn bounded_line_accepts_exact_limit_and_final_line() { let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec())); - assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234")); - assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!")); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"1234"[..])); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"last!"[..])); assert!(bounded_line(&mut input, 5).await.unwrap().is_none()); let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec())); @@ -143,4 +148,42 @@ mod tests { let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap(); assert_eq!(documents, 0, "the oversized tool call must not run"); } + + async fn next_reply(replies: &mut tokio::io::Lines>) -> Value { + let line = tokio::time::timeout(std::time::Duration::from_secs(5), replies.next_line()).await.expect("a reply in time").unwrap().expect("a reply line"); + serde_json::from_str(&line).unwrap() + } + + #[tokio::test] + async fn a_line_that_is_not_utf8_gets_a_parse_error_and_the_session_keeps_serving() { + let (mut client, input) = tokio::io::duplex(1 << 16); + let (output, replies) = tokio::io::duplex(1 << 16); + let server = PhotocraftMcp::headless(); + let observer = server.clone(); + let session = tokio::spawn(server.serve_io(input, output)); + let mut replies = BufReader::new(replies).lines(); + + let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "utf8-test", "version": "1"} + }}); + client.write_all(format!("{initialize}\n").as_bytes()).await.unwrap(); + assert_eq!(next_reply(&mut replies).await["id"], 1); + client.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n").await.unwrap(); + + client.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\xff\xfe}\n").await.unwrap(); + let error = next_reply(&mut replies).await; + assert_eq!(error["error"]["code"], -32700, "{error}"); + assert_eq!(error["id"], Value::Null, "{error}"); + + let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_new", "arguments": {"width": 8, "height": 8}}}); + client.write_all(format!("{call}\n").as_bytes()).await.unwrap(); + let reply = next_reply(&mut replies).await; + assert_eq!(reply["id"], 2, "{reply}"); + assert_ne!(reply["result"]["isError"], true, "{reply}"); + let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap(); + assert_eq!(documents, 1, "the call after the bad line must run"); + + drop(client); + session.await.unwrap().unwrap(); + } } diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index f73a702385..90fbdcd1ab 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -74,6 +74,13 @@ impl AuthorizedWorkspace { /// as [`photocraft_format::atomic_write`], through the directory capability). On failure /// the previous file is untouched and the temporary file is removed. /// + /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` — + /// onto the file that takes its place (#7095), through the two open descriptors rather than + /// their paths, so nothing here reaches outside the write root to finish a write. The rest + /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission + /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this + /// path may not use. + /// /// The parent directory must already exist. `cap-std` performs path /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. @@ -85,10 +92,20 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let mut file = file.into_std(); file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + // Best effort, and only when there is a file being replaced: a save still + // succeeds for a document whose Finder tag could not come along. + if let Ok(old) = root.dir.open(&relative) { + let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + } + } // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) + photocraft_format::atomic::sync_file(&file) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -323,6 +340,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { } "image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"], "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"], + // New Document's Color Profile (#321) can name an .icc file. + "file.new" => &["profile"], "edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"], _ => &[], }; @@ -339,6 +358,13 @@ fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool { if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") { return true; } + // New Document skips exact `working` and `none`; the other working aliases resolve + // the configured working space, which may be an .icc path. + if id == "file.new" { + return params.get("profile").and_then(Value::as_str).is_some_and(|profile| { + matches!(profile, "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray") + }); + } matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo") && params.get("profile").and_then(Value::as_str).is_some_and(|profile| { matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray") @@ -357,18 +383,27 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool { fn preference_uses_ambient_filesystem(path: &str) -> bool { // The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so // judge the first non-empty one; no segment at all is the whole-preferences update. - let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true }; + let mut segments = path.split('.').filter(|segment| !segment.is_empty()); + let Some(section) = segments.next() else { return true }; + // File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not. matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks") + || (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder")) } fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" + | "brush.presets.importAbr" + | "brush.presets.importImage" + | "gradient.presets.importGrd" + | "plugin.install" + | "swatches.import" + | "layer.layerStyle.contours.load" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "actions.save" | "swatches.export" => params.get("path").is_some(), + "actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(), // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. "edit.keyboardShortcuts" => { params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) @@ -572,6 +607,32 @@ mod tests { 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"))] + #[test] + fn write_keeps_the_replaced_files_extended_attributes() { + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + let (inside, _, workspace) = roots("xattr"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() { + return; // a file system without extended attributes: nothing to assert + } + + workspace.write("doc.psd", b"replacement, longer").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer"); + let mut buf = [0u8; 64]; + let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped"); + assert_eq!(&buf[..len], b"tagged"); + assert!(temp_files(&inside).is_empty()); + } + #[cfg(unix)] #[test] fn write_into_read_only_folder_keeps_the_original() { @@ -716,6 +777,12 @@ mod tests { assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working"})).is_ok()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "none"})).is_ok()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "default"})).is_err()); + assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working-cmyk"})).is_err()); // Shortcut sets: text in and out is fine; a path reads or writes the filesystem. let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p); assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": ""})).is_ok()); @@ -778,6 +845,8 @@ mod tests { ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), + ("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})), + ("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -790,6 +859,8 @@ mod tests { ("swatches.export", serde_json::json!({"format": "ase"})), ("actions.load", serde_json::json!({"data": "QUJD"})), ("actions.save", serde_json::json!({"set": "S"})), + ("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})), + ("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } @@ -804,6 +875,8 @@ mod tests { "historyLog.filePath", ".historyLog.filePath", "plugIns.additionalPluginsFolder", + "fileHandling.lutFolder", + "fileHandling", "scratchDisks.disks", ".", "..historyLog.filePath", @@ -814,6 +887,7 @@ mod tests { authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err() ); assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); + assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok()); } #[test] @@ -844,7 +918,9 @@ mod tests { } fn documents_filesystem_path_params(id: &str, params: &str) -> bool { - if matches!(id, "prefs.get" | "prefs.reset") { + // These commands return settings or installed-profile paths; they do not accept a host + // filesystem path as an automation input. + if matches!(id, "prefs.get" | "prefs.reset" | "edit.installedProfiles") { return false; } // These registry descriptions refer to document vector paths, not host files. diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/it/agent_tasks.rs similarity index 100% rename from crates/automation/tests/agent_tasks.rs rename to crates/automation/tests/it/agent_tasks.rs diff --git a/crates/automation/tests/it/agent_tasks_extended.rs b/crates/automation/tests/it/agent_tasks_extended.rs new file mode 100644 index 0000000000..4f950ed1a8 --- /dev/null +++ b/crates/automation/tests/it/agent_tasks_extended.rs @@ -0,0 +1,184 @@ +//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD). +//! +//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the +//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the +//! exported files, never engine internals. + +use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp}; +use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig}; +use rmcp::service::RunningService; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Value, json}; + +#[derive(Clone, Default)] +struct Client; +impl ClientHandler for Client { + fn get_info(&self) -> ClientConfig { + ClientConfig::default() + } +} + +type Conn = RunningService; +type Server = tokio::task::JoinHandle<()>; + +async fn connect(root: &std::path::Path) -> (Conn, Server) { + let (s, c) = tokio::io::duplex(1 << 20); + let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace"); + let server = tokio::spawn(async move { + if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await { + let _ = running.waiting().await; + } + }); + (Client.serve(c).await.expect("client init"), server) +} + +async fn shutdown(c: Conn, server: Server) { + c.cancel().await.unwrap(); + server.await.unwrap(); +} + +fn text(r: &CallToolResult) -> String { + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") +} + +/// Call a tool and parse its JSON result, failing the test on a tool error. +async fn tool(c: &Conn, name: &str, args: Value) -> Value { + let mut p = CallToolRequestParams::new(name.to_owned()); + if let Value::Object(m) = args { + p = p.with_arguments(m); + } + let r = c.call_tool(p).await.expect("call_tool transport"); + assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r)); + serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r))) +} + +async fn run(c: &Conn, id: &str, params: Value) -> Value { + tool(c, "command_run", json!({"id": id, "params": params})).await +} + +async fn inspect(c: &Conn) -> Value { + tool(c, "doc_inspect", json!({})).await +} + +async fn pixel(c: &Conn, x: i32, y: i32) -> Vec { + let v = run(c, "document.pixel", json!({"x": x, "y": y})).await; + let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) }; + arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect() +} + +fn top_layer(doc: &Value) -> &Value { + &doc["layers"][0] +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn close(a: &[f64], b: &[f64], tol: f64) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping, +/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip +/// that preserves a group. +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_ten_extended_tasks() { + let dir = tmp("tasks3"); + let (c, server) = connect(&dir).await; + + // 21. Paint a red background and cover it with a solid-colour fill layer. + tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await; + run(&c, "select.all", json!({})).await; + run(&c, "edit.fill", json!({"color": "#ff0000"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21"); + let cyan = pixel(&c, 32, 32).await; + assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}"); + + // 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan. + run(&c, "layer.newAdjustmentLayer.invert", json!({})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22"); + let inverted = pixel(&c, 32, 32).await; + assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan"); + + // 23. Hide a rectangular selection behind the layer's mask. + run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows + run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await; + run(&c, "layer.layerMask.hideSelection", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["hasMask"], true, "task 23"); + let hidden = pixel(&c, 32, 32).await; + let kept = pixel(&c, 4, 4).await; + assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}"); + assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}"); + run(&c, "layer.layerMask.delete", json!({})).await; + + // 24. Group every layer; the pixels are unchanged. + let before = pixel(&c, 32, 32).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}"); + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only"); + + // 25. Flatten the grouped document to one layer. + run(&c, "layer.flattenImage", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25"); + + // 26. Duplicate the layer and delete the copy. + run(&c, "layer.duplicate", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26"); + run(&c, "layer.delete", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26"); + + // 27. A Curves adjustment darkens the red; deleting it restores the original. + run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await; + let dark = pixel(&c, 32, 32).await; + assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red"); + + // 28. Hue/Saturation at -100 desaturates the red to gray. + run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await; + let gray = pixel(&c, 32, 32).await; + assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}"); + run(&c, "layer.delete", json!({})).await; + + // 29. Save a selection to a channel, load it back and paint through it. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await; + run(&c, "select.saveSelection", json!({"name": "Box"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.loadSelection", json!({"channel": "Box"})).await; + run(&c, "edit.fill", json!({"color": "#0000ff"})).await; + let painted = pixel(&c, 32, 32).await; + let outside = pixel(&c, 4, 4).await; + assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}"); + assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}"); + run(&c, "select.deselect", json!({})).await; + + // 30. Save natively with a group, reopen and find the group and its content intact. + run(&c, "layer.new.layer", json!({"name": "Badge"})).await; + run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await; + run(&c, "edit.fill", json!({"color": "#00ff00"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({"name": "Set"})).await; + tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await; + tool(&c, "doc_close", json!({})).await; + tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}"); + assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}"); + let green = pixel(&c, 48, 48).await; + assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}"); + tool(&c, "doc_close", json!({})).await; + + shutdown(c, server).await; + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/agent_tasks_more.rs b/crates/automation/tests/it/agent_tasks_more.rs similarity index 100% rename from crates/automation/tests/agent_tasks_more.rs rename to crates/automation/tests/it/agent_tasks_more.rs diff --git a/crates/automation/tests/bridge.rs b/crates/automation/tests/it/bridge.rs similarity index 100% rename from crates/automation/tests/bridge.rs rename to crates/automation/tests/it/bridge.rs diff --git a/crates/automation/tests/inactive_save.rs b/crates/automation/tests/it/inactive_save.rs similarity index 100% rename from crates/automation/tests/inactive_save.rs rename to crates/automation/tests/it/inactive_save.rs diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/it/krita.rs similarity index 95% rename from crates/automation/tests/krita.rs rename to crates/automation/tests/it/krita.rs index 85bde4f5fc..44de53ff2a 100644 --- a/crates/automation/tests/krita.rs +++ b/crates/automation/tests/it/krita.rs @@ -4,14 +4,15 @@ use photocraft_automation::Headless; use serde_json::json; -/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile +/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes. fn tiles(bgra: [u8; 4]) -> Vec { let n = 64 * 64; let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); out.push(0); - for b in bgra { - out.extend(std::iter::repeat_n(b, n)); + for _ in 0..n { + out.extend(bgra); } out } diff --git a/crates/automation/tests/layered_save_dirty.rs b/crates/automation/tests/it/layered_save_dirty.rs similarity index 100% rename from crates/automation/tests/layered_save_dirty.rs rename to crates/automation/tests/it/layered_save_dirty.rs diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 0000000000..f701bd705b --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,16 @@ +//! The crate's integration tests, linked into one test binary. + +mod agent_tasks; +mod agent_tasks_extended; +mod agent_tasks_more; +mod bridge; +mod inactive_save; +mod krita; +mod layered_save_dirty; +mod mcp; +mod mcp_wire; +mod missing_font_warnings; +mod native_save_name; +mod openraster; +mod raw_defaults; +mod smart_filter_authorization; diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/it/mcp.rs similarity index 96% rename from crates/automation/tests/mcp.rs rename to crates/automation/tests/it/mcp.rs index 7386f3d58c..88d62f6595 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -318,6 +318,10 @@ async fn errors_are_tool_errors_not_crashes() { assert!(text(&r).contains("bridge")); let r = call(&client, "doc_export", json!({})).await; assert_eq!(r.is_error, Some(true)); + // Quality 0 is refused, not clamped to 1 (serde already refuses 500, -1 and "85"). + let r = call(&client, "doc_save", json!({"path": "q.jpg", "quality": 0})).await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("from 1 to 100"), "{}", text(&r)); // Server still alive. json_of(&call(&client, "session_list", json!({})).await); client.cancel().await.unwrap(); @@ -415,6 +419,45 @@ async fn headless_doc_save_writes_targa_bits() { cleanup(&dir); } +/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown +/// compression is refused before anything is sent. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_dds_options() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await); + assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}})); + let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(text(&r).contains("ddsCompression"), "{}", text(&r)); + client.cancel().await.unwrap(); + app.abort(); +} + +/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip +/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_writes_dds_options() { + let dir = tmp("dds-options"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await; + for (tool, params, fourcc, mips) in [ + ("doc_export", json!({"path": "plain.dds"}), [0; 4], 1), + ("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1), + ("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}"); + } + let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(!dir.join("bad.dds").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; @@ -731,11 +774,12 @@ async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_au let client = connect(PhotocraftMcp::headless()).await; let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await); json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await); - run("layer.smartObjects.convertToSmartObject", json!({})).await; let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone(); // An explicit target works even with a regular pixel layer selected. run("layer.new.layer", json!({"name": "Other layer"})).await; assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1); + // A smart object cannot be converted again, so nest one inside the contents instead. + run("layer.smartObjects.convertToSmartObject", json!({})).await; assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2); run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await; assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true); diff --git a/crates/automation/tests/mcp_wire.rs b/crates/automation/tests/it/mcp_wire.rs similarity index 100% rename from crates/automation/tests/mcp_wire.rs rename to crates/automation/tests/it/mcp_wire.rs diff --git a/crates/automation/tests/missing_font_warnings.rs b/crates/automation/tests/it/missing_font_warnings.rs similarity index 100% rename from crates/automation/tests/missing_font_warnings.rs rename to crates/automation/tests/it/missing_font_warnings.rs diff --git a/crates/automation/tests/native_save_name.rs b/crates/automation/tests/it/native_save_name.rs similarity index 100% rename from crates/automation/tests/native_save_name.rs rename to crates/automation/tests/it/native_save_name.rs diff --git a/crates/automation/tests/openraster.rs b/crates/automation/tests/it/openraster.rs similarity index 100% rename from crates/automation/tests/openraster.rs rename to crates/automation/tests/it/openraster.rs diff --git a/crates/automation/tests/it/raw_defaults.rs b/crates/automation/tests/it/raw_defaults.rs new file mode 100644 index 0000000000..0f12e06c5e --- /dev/null +++ b/crates/automation/tests/it/raw_defaults.rs @@ -0,0 +1,20 @@ +//! #7789: the headless (MCP) `doc.open` opens a developed raw at Preferences › Raw Defaults › +//! Resolution, as the app's File › Open does. + +use photocraft_automation::Headless; +use photocraft_raw::testgen::{DngSpec, mosaic, scene}; +use serde_json::json; + +#[test] +fn doc_open_gives_a_raw_the_raw_defaults_resolution() { + let dir = std::env::temp_dir().join(format!("pc-raw-defaults-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (24, 16); + let path = dir.join("IMG_0001.dng"); + std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + let mut headless = Headless::trusted_local(); + headless.handle("engine.execute", json!({"command": "prefs.set", "params": {"path": "rawDefaults.resolution", "value": 240}})).unwrap(); + headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(headless.session.active().unwrap().doc.resolution_dpi, 240.0); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/it/smart_filter_authorization.rs similarity index 90% rename from crates/automation/tests/smart_filter_authorization.rs rename to crates/automation/tests/it/smart_filter_authorization.rs index 30f17b795b..594832c41d 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/it/smart_filter_authorization.rs @@ -257,3 +257,26 @@ fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); } } + +/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read +/// before the edit: a path outside the workspace is refused before that read, and the filter +/// keeps the map it had. +#[test] +fn a_new_displace_map_outside_the_workspace_is_refused_before_it_is_read() { + let f = Fixture::new(); + let (mut h, layer) = f.setup(false); + let params = |h: &Headless| { + let d = h.session.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params.clone() + }; + let before = params(&h); + let map = photocraft_io::smart_map::displace_embedded_map(&before).expect("the open document's map, embedded"); + assert!(map.data.iter().all(|v| *v < 0.9), "the grey map"); + let doc = h.session.active().unwrap().doc.clone(); + let edit = json!({"layer": layer.0, "params": {"mapPath": f.outside("white.png")}}); + assert_denied(h.session.execute("layer.smartFilter.setParams", edit.clone())); + assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": edit}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + assert_eq!(params(&h), before, "no white map was read into the filter"); +} diff --git a/crates/cms/README.md b/crates/cms/README.md index c48d5366fd..9109f48ed2 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning). * `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture. +* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for + display shaders. Float tables retain near-black detail; unsupported chains return `None` + for the 3D LUT fallback. No GPU dependency is required by this crate. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. * `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of @@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon (native only). +* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus + offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated + separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB + 33³ table cannot resolve steep inverse gamma curves near black, even with float texels. * Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero, negative or non-finite white is refused with `CmsError::Invalid`); BPC uses @@ -68,7 +75,7 @@ sampled gamut boundary. `profiles/photocraft-coated-cmyk.icc` (237 KB, AToB 11 21³ lut16) is that output, dedicated to the public domain (CC0-1.0). Regenerate with ```sh -PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen +PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen:: ``` It is a plausible coated-offset profile, not a measured characterisation: use your printer's diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 8f3dc88a15..30a31aceb5 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -261,13 +261,60 @@ fn lab() -> Profile { /// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`). pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc"); +/// Rec. 709 / sRGB primaries (xy). +const BT709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]]; +/// DCI-P3 primaries (xy), used with D65 by Display P3. +const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; +/// Rec. 2020 primaries (xy). +const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. +fn rec709_trc() -> Curve { + let a = 1.099; + Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] } +} + +/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video +/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB, +/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the +/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are +/// held, since no ICC curve represents those transfers. `None` for anything else. +pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option { + let (prim, name) = match primaries { + 1 => (BT709, "BT.709"), + 9 => (BT2020, "Rec. ITU-R BT.2020"), + 12 => (P3, "Display P3"), + _ => return None, + }; + let transfer = if linear { 8 } else { transfer }; + let builtin = match (primaries, transfer) { + (1, 13) => Some(Builtin::Srgb), + (1, 8) => Some(Builtin::LinearSrgb), + (12, 13) => Some(Builtin::DisplayP3), + (9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020), + _ => None, + }; + if let Some(b) = builtin { + return Some(b.profile().clone()); + } + let (trc, curve) = match transfer { + 8 => (Curve::Gamma(1.0), "linear"), + 13 => (srgb_trc(), "sRGB curve"), + 1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"), + 4 => (Curve::Gamma(2.2), "gamma 2.2"), + 5 => (Curve::Gamma(2.8), "gamma 2.8"), + _ => return None, + }; + Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, d65(), trc)) +} + fn build(b: Builtin) -> Profile { let (srgb_p, p3, adobe, romm, bt2020, ap1) = ( - [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], - [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + BT709, + P3, [[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], [[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], - [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + BT2020, [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], ); match b { @@ -279,12 +326,7 @@ fn build(b: Builtin) -> Profile { // ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60, // xy 0.32168/0.33767, the ACES 2065-1 neutral). Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)), - Builtin::Rec2020 => { - // Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. - let a = 1.099; - let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }; - rgb(b.description(), bt2020, d65(), trc) - } + Builtin::Rec2020 => rgb(b.description(), bt2020, d65(), rec709_trc()), Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)), Builtin::SGray => gray(b.description(), srgb_trc()), Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)), @@ -294,3 +336,30 @@ fn build(b: Builtin) -> Profile { Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cicp_maps_onto_the_builtins_and_linear_bt2020() { + let desc = |p: Option| p.map(|p| p.description); + assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string())); + assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string())); + assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string())); + assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string())); + + // An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve. + let hlg = cicp_rgb(9, 18, true).unwrap(); + assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)])); + assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix); + assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description); + assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile"); + + // HLG and PQ have no ICC curve; unknown primaries have no profile. + assert!(cicp_rgb(9, 18, false).is_none()); + assert!(cicp_rgb(9, 16, false).is_none()); + assert!(cicp_rgb(2, 13, false).is_none()); + assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none()); + } +} diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs index 186cd3f9f1..38992a2ed4 100644 --- a/crates/cms/src/clut.rs +++ b/crates/cms/src/clut.rs @@ -172,11 +172,16 @@ impl Clut { } let o = self.outputs; out[..o].iter_mut().for_each(|v| *v = 0.0); - for corner in 0..(1usize << n) { + // Only dimensions with more than one grid point contribute a cube axis. Degenerate + // dimensions have step 0 and ratio 0, so they always multiply the corner weight by 1 + // and never change the offset; enumerating 2^n corners over all declared inputs would + // do 2^(degenerate) redundant iterations. Interpolate over the effective dims only. + let eff: Vec = (0..n).filter(|&d| s[d] != 0).collect(); + for corner in 0..(1usize << eff.len()) { let mut w = 1.0f32; let mut off = base; - for d in 0..n { - if corner & (1 << d) != 0 { + for (bit, &d) in eff.iter().enumerate() { + if corner & (1 << bit) != 0 { w *= r[d]; off += s[d]; } else { @@ -245,4 +250,20 @@ mod tests { c.eval(&[0.5, 0.25], &mut o); assert!((o[0] - 0.75).abs() < 1e-6); } + + #[test] + fn degenerate_dimensions_do_not_change_the_result() { + // Dimensions with a single grid point contribute nothing, but the old code still + // enumerated 2^inputs corners over them. Interpolating over effective dims only must + // give the same answer as a reference built without the degenerate axes. + let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + // Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary. + let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f); + let mut got = [0.0]; + for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] { + c.eval_multilinear(&p, &mut got); + let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]); + } + } } diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index fe23e6e059..8b619842d1 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -99,6 +99,20 @@ impl Curve { (0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect() } + /// Inverse of the curve at each `y` in `ys`. For sampled-table curves the flat-run edge + /// indices are computed once for the whole batch instead of rescanning the table on every + /// call, so inverting a long constant table many times stays linear. + pub fn inverse_table(&self, ys: impl Iterator) -> Vec { + match self { + Curve::Table(t) if t.len() >= 2 => { + let increasing = t[t.len() - 1] >= t[0]; + let (edge_start, edge_end) = (edge_index(t, true), edge_index(t, false)); + ys.map(|y| table_inverse_with(t, y as f32, increasing, edge_start, edge_end)).collect() + } + _ => ys.map(|y| self.eval_inverse64(y) as f32).collect(), + } + } + /// `true` when the curve never decreases over `[0, 1]`. pub fn is_monotonic(&self) -> bool { let s = self.sample(1024); @@ -244,13 +258,24 @@ fn table_inverse(t: &[f32], y: f32) -> f32 { return y; } let increasing = t[n - 1] >= t[0]; + table_inverse_with(t, y, increasing, edge_index(t, true), edge_index(t, false)) +} + +/// [`table_inverse`] with the flat-run edge indices supplied. Computing those edges scans the +/// whole table, and transform construction inverts the same table thousands of times, so the +/// edges are computed once and reused across a batch (see [`Curve::inverse_table`]). +fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge_end: f32) -> f32 { + let n = t.len(); + if n < 2 { + return y; + } let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) }; if y <= lo_v { // Flat start: choose the last index still at the lowest value. - return if increasing { edge_index(t, true) } else { edge_index(t, false) }; + return if increasing { edge_start } else { edge_end }; } if y >= hi_v { - return if increasing { edge_index(t, false) } else { edge_index(t, true) }; + return if increasing { edge_end } else { edge_start }; } let (mut lo, mut hi) = (0usize, n - 1); while hi - lo > 1 { @@ -336,4 +361,24 @@ mod tests { let g = Curve::Gamma(2.2); assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + + #[test] + fn inverse_table_matches_per_call_including_flat_runs() { + // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat + // runs at both ends (the case that used to rescan the table on every call). + let tables = [ + Curve::Table(vec![0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.0, 1.0]), + Curve::Table(vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), + Curve::Table(vec![1.0, 0.5, 0.0]), + Curve::Gamma(2.2), + ]; + let ys: Vec = (0..=40).map(|i| i as f64 / 40.0).collect(); + for c in &tables { + let batch = c.inverse_table(ys.iter().copied()); + for (i, &y) in ys.iter().enumerate() { + let one = c.eval_inverse64(y) as f32; + assert!((batch[i] - one).abs() < 1e-6, "y={y}: batch {} vs per-call {one}", batch[i]); + } + } + } } diff --git a/crates/cms/src/gpu_matrix_shaper.rs b/crates/cms/src/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..09631bf98d --- /dev/null +++ b/crates/cms/src/gpu_matrix_shaper.rs @@ -0,0 +1,131 @@ +//! Separable RGB display transforms without a coarse 3D interpolation near black. + +/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display +/// transform. Values are floats independent of the document's pixel depth. +/// +/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply +/// [`Self::matrix_rows`] to that result, then sample the output table at +/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel +/// separately between its two adjacent table entries. These nonuniform output samples +/// retain shadow detail where a pure gamma curve's inverse has an infinite slope. +/// +/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels. +/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable. +/// This representation covers SDR display output; input and output are clamped to `[0, 1]`. +/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the +/// [`crate::Lut3d`] fallback. +#[derive(Clone, Debug, PartialEq)] +pub struct GpuMatrixShaper { + matrix: [[f32; 4]; 3], + input: Vec<[f32; 4]>, + output: Vec<[f32; 4]>, +} + +impl GpuMatrixShaper { + /// The input table has one more entry than this number of equal intervals. + pub const INPUT_INTERVALS: usize = 4096; + /// Output entries are spaced uniformly in the fourth root of the linear value. + pub const OUTPUT_ENTRIES: usize = 4096; + + /// Row-major matrix: each row contains three RGB coefficients followed by its offset. + pub fn matrix_rows(&self) -> [[f32; 4]; 3] { + self.matrix + } + + /// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1. + pub fn input_table(&self) -> &[[f32; 4]] { + &self.input + } + + /// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1. + pub fn output_table(&self) -> &[[f32; 4]] { + &self.output + } + + pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec], output: Option<&[Vec]>) -> Option { + if !matrix.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?; + let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?; + let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max)); + for row in matrix { + let magnitude = + row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::() + f64::from(row[3]).abs(); + if magnitude > f64::from(f32::MAX) { + return None; + } + } + Some(Self { matrix, input, output }) + } +} + +fn pack_table(tables: Option<&[Vec]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option> { + if let Some(tables) = tables + && (tables.len() != 3 || tables.iter().any(|t| t.len() != entries)) + { + return None; + } + let mut packed: Vec<[f32; 4]> = Vec::new(); + // Both lengths are fixed constants, but a failed allocation still means the caller + // should retain its existing fallback rather than abort while opening a document. + packed.try_reserve_exact(entries).ok()?; + for i in 0..entries { + let rgb = match tables { + Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?], + None => [identity(i as f32 / (entries - 1) as f32); 3], + }; + if !rgb.iter().all(|v| v.is_finite()) { + return None; + } + if let Some(previous) = packed.last() + && rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite()) + { + return None; + } + packed.push([rgb[0], rgb[1], rgb[2], 1.0]); + } + Some(packed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + + #[test] + fn rejects_invalid_tables_and_nonfinite_values() { + let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3]; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none()); + let mut invalid = inputs.clone(); + invalid[0].pop(); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut invalid = inputs.clone(); + invalid[1][123] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + let mut invalid = outputs.clone(); + invalid[2][321] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none()); + let mut invalid = IDENTITY; + invalid[1][3] = v; + assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none()); + } + } + + #[test] + fn rejects_finite_data_that_overflows_shader_arithmetic() { + let mut matrix = IDENTITY; + matrix[0] = [f32::MAX; 4]; + assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none()); + let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + inputs[0][100] = -f32::MAX; + inputs[0][101] = f32::MAX; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none()); + } +} diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index f93b00372b..259d51bf77 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -8,6 +8,7 @@ //! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible, //! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile. //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. +//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, //! CIE 15, and Adobe's published black point compensation paper. This crate depends on no @@ -19,6 +20,7 @@ pub mod builtin; pub mod clut; pub mod curve; pub mod gamut; +mod gpu_matrix_shaper; pub mod lut3d; pub mod lutfile; pub mod math; @@ -32,6 +34,7 @@ pub use builtin::Builtin; pub use clut::Clut; pub use curve::Curve; pub use gamut::GamutCheck; +pub use gpu_matrix_shaper::GpuMatrixShaper; pub use lut3d::Lut3d; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached}; diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 47ff164fda..240aac36f8 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -107,7 +107,14 @@ pub fn parse_cube(text: &str) -> Result { let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]); let mut rows: Vec = Vec::new(); for line in text.lines() { - let line = line.split('#').next().unwrap_or("").trim(); + // A '#' inside a quoted TITLE is part of the title, not a comment. + let raw = line.trim_start(); + let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0); + let line = match raw[skip..].find('#') { + Some(i) => &raw[..skip + i], + None => raw, + } + .trim(); if line.is_empty() { continue; } @@ -230,6 +237,10 @@ pub fn write_cube(l: &LutFile) -> String { s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'"))); } s.push_str(&format!("LUT_3D_SIZE {}\n", l.size)); + if l.domain_min != [0.0; 3] || l.domain_max != [1.0; 3] { + s.push_str(&format!("DOMAIN_MIN {} {} {}\n", l.domain_min[0], l.domain_min[1], l.domain_min[2])); + s.push_str(&format!("DOMAIN_MAX {} {} {}\n", l.domain_max[0], l.domain_max[1], l.domain_max[2])); + } for c in l.data.as_chunks::<3>().0 { s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2])); } @@ -301,6 +312,32 @@ pub fn builtin(id: &str) -> Option { mod tests { use super::*; + #[test] + fn cube_title_with_hash_round_trips() { + let l = LutFile::from_fn("Look #3", 2, |c| c); + assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3"); + let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n"; + let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let p = parse_cube(&format!("{t}{rows}")).unwrap(); + assert_eq!(p.title, "A #b"); + } + + #[test] + fn write_cube_keeps_non_default_domain() { + let mut l = LutFile::identity(2); + l.domain_min = [0.1; 3]; + l.domain_max = [0.9; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.1; 3]); + assert_eq!(back.domain_max, [0.9; 3]); + l.domain_min = [0.0, 0.0, 0.5]; + l.domain_max = [1.0; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.0, 0.0, 0.5]); + assert_eq!(back.domain_max, [1.0; 3]); + assert!(!write_cube(&LutFile::identity(2)).contains("DOMAIN")); + } + #[test] fn cube_domain_is_kept_and_validated() { let base = "TITLE \"t\" diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index d262956a78..f30c17c82e 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -9,7 +9,7 @@ use crate::curve::Curve; use crate::math; use crate::pipeline::{Pipeline, Stage}; use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass}; -use crate::{CmsError, Intent}; +use crate::{CmsError, GpuMatrixShaper, Intent}; /// Sample storage of a byte buffer for [`Transform::convert_bytes_many`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -39,9 +39,9 @@ impl Default for TransformOptions { /// Grid sizes of the device-link tables built for integer (and fast float) evaluation. pub const GRID_3D: usize = 33; pub const GRID_4D: usize = 17; -const TABLE_1D: usize = 4096; +const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS; /// Resolution of output shaper tables (indexed by `x^(1/4)`). -const OUT_TABLE: usize = 4096; +const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES; /// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ. const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287]; @@ -133,12 +133,11 @@ impl Transform { let tables = cs .iter() .map(|c| { - (0..OUT_TABLE) - .map(|i| { - let u = i as f64 / (OUT_TABLE - 1) as f64; - c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32 - }) - .collect() + let ys = (0..OUT_TABLE).map(|i| { + let u = i as f64 / (OUT_TABLE - 1) as f64; + u * u * u * u + }); + c.inverse_table(ys).into_iter().map(|v| v.clamp(0.0, 1.0)).collect() }) .collect(); post = Some(Post { tables }); @@ -226,6 +225,31 @@ impl Transform { &self.pipeline } + /// A separable RGB transform for accurate GPU display conversion, when available. + /// + /// Reuses the input/matrix/output acceleration of the integer conversion path instead + /// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other + /// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers + /// can keep their existing display-LUT fallback. This is an SDR representation; see + /// [`GpuMatrixShaper`] for the precise sampling and clamping contract. + pub fn gpu_matrix_shaper(&self) -> Option { + if self.inputs != 3 || self.outputs != 3 { + return None; + } + let Core::Shaper { curves, matrix, .. } = &self.core else { return None }; + let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + if let Some((nr, nc, coefficients, offsets)) = matrix { + if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 { + return None; + } + for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) { + let [r, g, b] = coefficients; + *row = [*r, *g, *b, *offset]; + } + } + GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice())) + } + /// Exact evaluation of one colour (`inputs` → `outputs` normalised values). #[inline] pub fn eval(&self, input: &[f32], out: &mut [f32]) { diff --git a/crates/cms/tests/bench.rs b/crates/cms/tests/it/bench.rs similarity index 95% rename from crates/cms/tests/bench.rs rename to crates/cms/tests/it/bench.rs index e0b9628a53..06789ff350 100644 --- a/crates/cms/tests/bench.rs +++ b/crates/cms/tests/it/bench.rs @@ -1,4 +1,4 @@ -//! Throughput check: `cargo test -p photocraft-cms --release --test bench -- --ignored --nocapture`. +//! Throughput check: `cargo test -p photocraft-cms --release --test it -- bench:: --ignored --nocapture`. use photocraft_cms::{Builtin, Intent, Transform}; diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/it/cms.rs similarity index 100% rename from crates/cms/tests/cms.rs rename to crates/cms/tests/it/cms.rs diff --git a/crates/cms/tests/it/gpu_matrix_shaper.rs b/crates/cms/tests/it/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..20f05e8aa1 --- /dev/null +++ b/crates/cms/tests/it/gpu_matrix_shaper.rs @@ -0,0 +1,197 @@ +//! Independent ICC matrix/TRC reference for display shadows. +//! +//! Fixtures are generated here from the published sRGB equation and a synthetic monitor, +//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3 +//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math: +//! https://www.color.org/specification/ICC1v43_2010-12.pdf + +use photocraft_cms::pipeline::{Pipeline, Stage}; +use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions}; + +const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]]; +// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile. +const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]]; +const GAMMA: f64 = 2.2; +const SOURCE_ENTRIES: usize = 1024; +const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0; + +fn fixed16(x: f64) -> f64 { + (x * 65536.0).round() / 65536.0 +} + +fn srgb_sample(i: usize) -> f64 { + let x = i as f64 / (SOURCE_ENTRIES - 1) as f64; + let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + (linear * 65535.0).round() / 65535.0 +} + +fn synthetic_profiles() -> (Profile, Profile) { + let mut source = Builtin::Srgb.profile().clone(); + source.description = "Synthetic sampled sRGB for display regression".into(); + source.matrix = Some(SOURCE_MATRIX); + source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect()))); + let source = source.with_encoded_bytes(); + let mut monitor = Builtin::Srgb.profile().clone(); + monitor.description = "Synthetic gamma 2.2 display for regression".into(); + monitor.matrix = Some(MONITOR_MATRIX); + monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] })); + let monitor = monitor.with_encoded_bytes(); + // Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values. + (Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap()) +} + +fn synthetic_transform(bpc: bool) -> Transform { + let (source, monitor) = synthetic_profiles(); + Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap() +} + +fn reference(rgb: [f32; 3]) -> [f64; 3] { + let linear = rgb.map(|x| { + let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64; + let i = (p as usize).min(SOURCE_ENTRIES - 2); + srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64) + }); + let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::()); + // Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production + // matrix inverse/multiplication implementation. + let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]); + for pivot in 0..3 { + let divisor = a[pivot][pivot]; + for value in &mut a[pivot][pivot..] { + *value /= divisor; + } + let normalized = a[pivot]; + for (r, row) in a.iter_mut().enumerate() { + if r != pivot { + let factor = row[pivot]; + for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) { + *value -= factor * coefficient; + } + } + } + } + std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA))) +} + +/// CPU interpretation of the public GPU data layout; expected colours come only from +/// `reference`, so this tests the sampled representation, not Transform against itself. +fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] { + fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 { + let i = (coordinate as usize).min(table.len() - 2); + let f = coordinate - i as f32; + table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f + } + let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32)); + let rows = gpu.matrix_rows(); + std::array::from_fn(|c| { + let row = rows[c]; + let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3]; + let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32; + interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0) + }) +} + +fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) { + let actual = sample_gpu(gpu, rgb); + let expected = reference(rgb); + for c in 0..3 { + assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}"); + } +} + +#[test] +fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() { + for bpc in [false, true] { + let transform = synthetic_transform(bpc); + let gpu = transform.gpu_matrix_shaper().unwrap(); + assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1); + assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES); + assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0)); + for v in 0..=255 { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + // Includes coloured shadows within the old LUT's very first 0..8/255 cell. + for r in 0..=16 { + for g in 0..=16 { + for b in 0..=16 { + check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); + } + } + } + for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] { + check_reference(&gpu, rgb); + } + } +} + +#[test] +fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + let coarse = Lut3d::from_transform(&transform, 33); + let rgb = [2.0 / 255.0; 3]; + let expected = reference(rgb); + let old = coarse.sample(rgb); + // Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This + // guards the cause of the original bug rather than a particular observed screenshot. + assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}"); + check_reference(&gpu, rgb); +} + +#[test] +fn gpu_display_representation_accepts_u8_u16_and_float_samples() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] { + check_reference(&gpu, [v as f32 / 65535.0; 3]); + } + for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] { + check_reference(&gpu, rgb); + } + assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]); + // White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16. + check_reference(&gpu, [1.0; 3]); +} + +#[test] +fn absent_curves_and_matrix_use_identity_without_losing_offsets() { + let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] { + for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) { + assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6); + } + } + let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] }; + let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + let input = [0.1, 0.2, 0.3]; + let output = sample_gpu(&transform, input); + for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) { + assert!((actual - expected).abs() < 1e-6, "{output:?}"); + } +} + +#[test] +fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() { + let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input)); + let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default()); + assert!(clut.gpu_matrix_shaper().is_none()); + let srgb = Builtin::Srgb.profile(); + let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(lab.gpu_matrix_shaper().is_none()); + let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(gray.gpu_matrix_shaper().is_none()); + let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap(); + assert!(proof.gpu_matrix_shaper().is_none()); +} + +#[test] +fn malformed_matrix_storage_is_not_exposed_to_the_gpu() { + for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] { + let transform = + Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default()); + assert!(transform.gpu_matrix_shaper().is_none()); + } +} diff --git a/crates/cms/tests/it/main.rs b/crates/cms/tests/it/main.rs new file mode 100644 index 0000000000..127f08435b --- /dev/null +++ b/crates/cms/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, linked into one test binary. + +mod bench; +mod cms; +mod gpu_matrix_shaper; +mod regen; diff --git a/crates/cms/tests/regen.rs b/crates/cms/tests/it/regen.rs similarity index 98% rename from crates/cms/tests/regen.rs rename to crates/cms/tests/it/regen.rs index 54db9b4e2e..05d4b696b3 100644 --- a/crates/cms/tests/regen.rs +++ b/crates/cms/tests/it/regen.rs @@ -1,7 +1,7 @@ //! Regenerates the shipped synthetic CMYK profile when `PHOTOCRAFT_REGEN_PROFILES=1`: //! //! ```sh -//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen +//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen:: //! ``` //! //! Without the variable the test only checks that the generator's model still matches the diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 46baf50f82..46a33e89b3 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -40,14 +40,16 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | -| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | +| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | | QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | -| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | +| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in | +| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in | +| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -66,14 +68,25 @@ the same. Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode. +`bmp_palette` returns the colours the header declares, not the decoder's 256-entry padding. +`encode_bmp_indexed` writes the inverse: indices plus a table as an 8-bit palette BMP. ## Documented asymmetries and limitations * **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), - with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are - ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats - (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. + with the legacy header or the DX10 one. Uncompressed row padding follows the header's + `DDSD_PITCH` stride; without that flag, rows are tightly packed. Everything opens as U8; the + other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes + a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or + without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from + Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's + principal axis and refined by least squares, indices picked against the palette the reader + decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks + whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter + of the one above (the last column and row of an odd-sized level average three pixels, so none + is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written. * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`. @@ -96,13 +109,34 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. -* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), +* **Cineon.** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white reference points are a log-conversion choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than - panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. + panicking. Writing follows the Cineon 4.5 layout: a 1024-byte generic header (version `V4.5`, + one or three channels designated as universal-metric gray or R, G, B, 10 bits, data 0–1023 over + densities 0–2.048) and a 1024-byte film header left undefined, then the samples three to a + big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written + as `round(v × 1023 / 65535)`, so a frame opened from Cineon keeps its code values; any other + image takes the encode plan (alpha dropped, CMYK and Lab to RGB) and is quantized to 10 bits. + +* **DICOM (read-only).** Single-frame medical images over the uncompressed transfer syntaxes + (implicit VR little-endian, explicit VR little/big-endian): MONOCHROME1/2 gray (with the + 1 = white inversion) and RGB/YBR_FULL, 8- and 16-bit allocated samples, interleaved or planar, + scaled from `BitsStored`/`HighBit` and mapped up when `PixelRepresentation` is signed. + Undefined-length sequences outside the pixel data are skipped, as clinical files contain them. + Compressed transfer syntaxes, sequences over the pixel data and other photometric + interpretations return `Unsupported`/`Malformed`. There is no DICOM writer + (`ASYMMETRIC_EXCEPTIONS`). + +* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes; + `FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support + stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing + entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB + viewport modes, PBM mask planes, other bit depths and compressions return + `CodecError::Unsupported`. There is no IFF writer; save as PNG. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes @@ -137,7 +171,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes from `decode_gif_frames`: each frame composited onto the logical screen with the disposal methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and - `MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`). + `MAX_GIF_FRAMES`. Every frame of an animated WebP comes from `decode_webp_frames` the same + way, composited onto its canvas, with its duration in milliseconds and the file's ICC profile. + The video-layer commands use both (`photocraft_io::import_gif_frames`, + `photocraft_io::import_webp_frames`). * **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet. `caps.exif = false` for TIFF, and a warning is raised. * **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml index 62ec96c523..e01d1bd337 100644 --- a/crates/codecs/fuzz/Cargo.toml +++ b/crates/codecs/fuzz/Cargo.toml @@ -69,3 +69,10 @@ path = "fuzz_targets/decode_gif.rs" test = false doc = false bench = false + +[[bin]] +name = "decode_webp" +path = "fuzz_targets/decode_webp.rs" +test = false +doc = false +bench = false diff --git a/crates/codecs/fuzz/fuzz_targets/decode_webp.rs b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs new file mode 100644 index 0000000000..c1539e1600 --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs @@ -0,0 +1,13 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, decode_webp_frames, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + ..Default::default() + }; + let _ = decode_as_with(Format::WebP, data, &opts); + let _ = decode_webp_frames(data, &opts); +}); diff --git a/crates/codecs/src/cicp.rs b/crates/codecs/src/cicp.rs new file mode 100644 index 0000000000..fc148837cc --- /dev/null +++ b/crates/codecs/src/cicp.rs @@ -0,0 +1,182 @@ +//! ITU-T H.273 colour descriptions (CICP: coding-independent code points), as HEIF `colr` +//! `nclx` boxes and video streams declare them, and the HDR transfer functions of ITU-R BT.2100. +//! +//! PQ and HLG can't be written as an ICC tone curve, so an image using them is decoded here to +//! linear light (32-bit float) instead: diffuse white (BT.2408's 203 cd/m²) becomes 1.0 and the +//! highlights above it are kept as values over 1.0, as Photoshop holds HDR in a 32-bit document. +//! The caller tags the result with a linear profile on the declared primaries. + +use crate::error::CodecError; +use crate::image::{Image, SampleType}; + +/// A colour description: the four H.273 code points as written in the file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cicp { + /// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65… + pub primaries: u16, + /// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 8 linear, 16 PQ, 18 HLG… + pub transfer: u16, + /// Matrix coefficients. + pub matrix: u16, + /// Full-range samples rather than studio swing. + pub full_range: bool, +} + +impl Cicp { + /// SMPTE ST 2084 / BT.2100 perceptual quantizer. + pub const TRANSFER_PQ: u16 = 16; + /// ARIB STD-B67 / BT.2100 hybrid log-gamma. + pub const TRANSFER_HLG: u16 = 18; + + /// PQ or HLG: samples that [`linearize`] decodes to linear light. + pub fn is_hdr(self) -> bool { + matches!(self.transfer, Cicp::TRANSFER_PQ | Cicp::TRANSFER_HLG) + } +} + +/// The luminance that becomes 1.0: BT.2408's HDR reference (diffuse) white. +pub const REFERENCE_WHITE_NITS: f32 = 203.0; +/// The nominal display peak HLG is rendered for (BT.2100's reference display). +pub const HLG_PEAK_NITS: f32 = 1000.0; +/// PQ's absolute peak. +pub const PQ_PEAK_NITS: f32 = 10_000.0; + +/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1). +pub fn hlg_inverse_oetf(e: f32) -> f32 { + const A: f32 = 0.178_832_77; + const B: f32 = 1.0 - 4.0 * A; + const C: f32 = 0.559_910_7; + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + if e <= 0.5 { e * e / 3.0 } else { (((e - C) / A).exp() + B) / 12.0 } +} + +/// BT.2100 HLG signal to display light relative to [`REFERENCE_WHITE_NITS`]: the inverse OETF, +/// then the OOTF for a [`HLG_PEAK_NITS`] display (system gamma 1.2, zero black level), whose +/// luminance uses the weights of the `luma` primaries. +pub fn hlg_to_linear(rgb: [f32; 3], luma: [f32; 3]) -> [f32; 3] { + let s = rgb.map(hlg_inverse_oetf); + let ys = luma[0] * s[0] + luma[1] * s[1] + luma[2] * s[2]; + // γ = 1.2 + 0.42·log10(Lw / 1000) is exactly 1.2 at 1000 cd/m². + let gain = if ys > 0.0 { HLG_PEAK_NITS * ys.powf(0.2) / REFERENCE_WHITE_NITS } else { 0.0 }; + s.map(|v| v * gain) +} + +/// SMPTE ST 2084 (PQ) signal `E'` (0..1) to display light relative to [`REFERENCE_WHITE_NITS`]. +pub fn pq_to_linear(e: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let p = e.powf(1.0 / M2); + let y = ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1); + y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS +} + +/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the +/// space BT.2100 defines HLG in). +fn luma_weights(code: u16) -> [f32; 3] { + match code { + 1 => [0.2126, 0.7152, 0.0722], + 12 => [0.2290, 0.6917, 0.0793], + _ => [0.2627, 0.6780, 0.0593], + } +} + +/// `img` (RGB or RGBA, decoded with `cicp`'s PQ or HLG transfer) as linear light in 32-bit float, +/// alpha unchanged. Its ICC profile is dropped: whatever it said described the encoded samples, +/// not linear light. Anything else comes back unchanged. +pub fn linearize(img: Image, cicp: Cicp) -> Result { + let layout = img.layout(); + if !cicp.is_hdr() || !layout.is_rgb() { + return Ok(img); + } + let (w, h) = img.dimensions(); + let ch = layout.channels(); + let mut v = img.to_normalized(); + let luma = luma_weights(cicp.primaries); + for px in v.chunks_exact_mut(ch) { + let [r, g, b, ..] = *px else { continue }; + let out = if cicp.transfer == Cicp::TRANSFER_HLG { hlg_to_linear([r, g, b], luma) } else { [r, g, b].map(pq_to_linear) }; + px[..3].copy_from_slice(&out); + } + let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?; + out.meta = img.meta; + out.warnings = img.warnings; + out.cicp = Some(cicp); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image::ChannelLayout; + + const BT2020: [f32; 3] = [0.2627, 0.6780, 0.0593]; + + fn close(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol + } + + #[test] + fn hlg_inverse_oetf_matches_bt2100() { + assert_eq!(hlg_inverse_oetf(0.0), 0.0); + // The two segments meet at E' = 0.5, E = 1/12. + assert!(close(hlg_inverse_oetf(0.5), 1.0 / 12.0, 1e-6)); + assert!(close(hlg_inverse_oetf(0.500_001), 1.0 / 12.0, 1e-5)); + assert!(close(hlg_inverse_oetf(1.0), 1.0, 1e-4)); + // BT.2408's HLG reference white, 75 % signal, is E ≈ 0.265. + assert!(close(hlg_inverse_oetf(0.75), 0.2650, 1e-3)); + for bad in [f32::NAN, f32::INFINITY, -1.0] { + assert!(hlg_inverse_oetf(bad).is_finite()); + } + } + + #[test] + fn hlg_reference_white_is_one_and_highlights_are_kept() { + // BT.2408: a 75 % HLG grey on a 1000 cd/m² display is 203 cd/m², our 1.0. + let white = hlg_to_linear([0.75; 3], BT2020); + assert!(white.iter().all(|&v| close(v, 1.0, 0.005)), "{white:?}"); + // Peak signal is the display peak: 1000 / 203. + let peak = hlg_to_linear([1.0; 3], BT2020); + assert!(peak.iter().all(|&v| close(v, 1000.0 / 203.0, 0.01)), "{peak:?}"); + // 50 % grey: 1000 · (1/12)^1.2 ≈ 50.7 cd/m². + let mid = hlg_to_linear([0.5; 3], BT2020); + assert!(mid.iter().all(|&v| close(v * 203.0, 50.71, 0.1)), "{mid:?}"); + assert_eq!(hlg_to_linear([0.0; 3], BT2020), [0.0; 3]); + // A saturated colour keeps its hue: only red lit stays only red. + let red = hlg_to_linear([0.75, 0.0, 0.0], BT2020); + assert!(red[0] > 0.0 && red[1] == 0.0 && red[2] == 0.0, "{red:?}"); + } + + #[test] + fn pq_matches_st2084() { + assert_eq!(pq_to_linear(0.0), 0.0); + assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05)); + // 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408). + assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807)); + assert!(pq_to_linear(f32::NAN).is_finite()); + } + + #[test] + fn linearize_decodes_hlg_to_float_and_keeps_alpha() { + let hlg = Cicp { primaries: 9, transfer: Cicp::TRANSFER_HLG, matrix: 9, full_range: true }; + let three_quarters = (0.75f32 * 65535.0).round() as u16; + let img = Image::from_u16(2, 1, ChannelLayout::Rgba, &[three_quarters, three_quarters, three_quarters, 65535, 0, 0, 0, 32768]) + .unwrap() + .with_icc(Some(b"encoded-profile".to_vec())); + let out = linearize(img, hlg).unwrap(); + assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg))); + let v = out.to_normalized(); + assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}"); + assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}"); + } + + #[test] + fn linearize_leaves_sdr_untouched() { + let srgb = Cicp { primaries: 1, transfer: 13, matrix: 6, full_range: false }; + let img = Image::from_u16(1, 1, ChannelLayout::Rgb, &[1, 2, 3]).unwrap(); + assert_eq!(linearize(img.clone(), srgb).unwrap(), img); + } +} diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index efe93c7f2e..cadc2b4bc8 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -1,13 +1,24 @@ -//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed -//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed -//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps, -//! texture arrays and volume textures are refused rather than flattened to one face or slice. +//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS", +//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10"). +//! +//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel +//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3 +//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume +//! textures are refused rather than flattened to one face or slice. +//! +//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or +//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these, +//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written. +use std::borrow::Cow; + +use super::dds_bc::{self, Block, Mask}; use crate::SampleType; use crate::error::CodecError; +use crate::fidelity::Plan; use crate::format::Format; use crate::image::{ChannelLayout, Image}; -use crate::options::Limits; +use crate::options::{DdsCompression, EncodeOptions, Limits}; /// `DDS ` plus the 124-byte DDS_HEADER. const HEADER: usize = 128; @@ -106,15 +117,29 @@ impl Encoding { } } - /// Bytes of the top mip level. - fn level_size(&self, width: u32, height: u32) -> Option { - let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); - let block = match self { - Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), - Encoding::Bc1 => 8, - Encoding::Bc2 | Encoding::Bc3 => 16, + /// Stored row stride and byte size of the top mip level (block rows for BC). + fn level_layout(&self, width: u32, height: u32, pitch: Option) -> Result<(usize, usize), CodecError> { + let overflow = || bad("pixel data size overflow"); + let (w, h) = (usize::try_from(width).map_err(|_| overflow())?, usize::try_from(height).map_err(|_| overflow())?); + let (row_bytes, rows) = match self { + Encoding::Masks { bytes, .. } => { + let packed = w.checked_mul(*bytes).ok_or_else(overflow)?; + let row_bytes = match pitch { + Some(pitch) => usize::try_from(pitch).map_err(|_| overflow())?, + None => packed, + }; + if row_bytes < packed { + return Err(bad(format!("row pitch {row_bytes} is smaller than the {packed}-byte pixel row"))); + } + (row_bytes, h) + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block = if matches!(self, Encoding::Bc1) { 8 } else { 16 }; + (w.div_ceil(4).checked_mul(block).ok_or_else(overflow)?, h.div_ceil(4)) + } }; - w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + let size = row_bytes.checked_mul(rows).ok_or_else(overflow)?; + Ok((row_bytes, size)) } } @@ -184,19 +209,25 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; let layout = encoding.layout(); limits.check(width, height, layout, SampleType::U8)?; - let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + // The same header field is a total level size for BC, but a row stride when DDSD_PITCH is set. + let pitch = if flags & DDSD_PITCH != 0 { Some(le32(b, 20)?) } else { None }; + let (row_pitch, size) = encoding.level_layout(width, height, pitch)?; let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; let (w, h) = (width as usize, height as usize); let channels = layout.channels(); - let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let row_size = w.checked_mul(channels).ok_or_else(|| bad("pixel size overflow"))?; + let total = row_size.checked_mul(h).ok_or_else(|| bad("pixel size overflow"))?; let mut pixels = vec![0u8; total]; match encoding { Encoding::Masks { bytes, gray, fields } => { - for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { - let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); - let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); - let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; - dst.copy_from_slice(&values[..channels]); + for (src_row, dst_row) in data.chunks_exact(row_pitch).zip(pixels.chunks_exact_mut(row_size)) { + // Stop at the output width, leaving any stored row padding unread. + for (src, dst) in src_row.chunks_exact(bytes).zip(dst_row.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } } } Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { @@ -224,27 +255,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { Image::from_u8(width, height, layout, pixels) } -fn rgb565(c: u16) -> [u8; 3] { - let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; - [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] -} - /// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's /// `c0 <= c1` order selects three colours and transparent black. fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] { let byte = |i: usize| block.get(i).copied().unwrap_or(0); let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)])); - let (a, b) = (rgb565(c0), rgb565(c1)); - let mix = |wa: u32, wb: u32| { - let d = wa + wb; - let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; - [at(0), at(1), at(2), 255] - }; - let palette = if c0 > c1 || !one_bit_alpha { - [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] - } else { - [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] - }; + let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha); let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]); std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) } @@ -259,21 +275,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] { /// 0 and 255 when `a0 <= a1`. fn bc3_alpha(block: &[u8]) -> [u8; 16] { let byte = |i: usize| block.get(i).copied().unwrap_or(0); - let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1))); - let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; - if a0 > a1 { - for (k, v) in palette.iter_mut().enumerate().skip(2) { - let k = k as u32; - *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; - } - } else { - for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { - let k = k as u32; - *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; - } - } + let palette = dds_bc::alpha_palette(byte(0), byte(1)); let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]); - std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8) + std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize]) } fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { @@ -282,3 +286,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { } texels } + +const DDSD_CAPS: u32 = 0x1; +const DDSD_HEIGHT: u32 = 0x2; +const DDSD_WIDTH: u32 = 0x4; +const DDSD_PITCH: u32 = 0x8; +const DDSD_PIXELFORMAT: u32 = 0x1000; +const DDSD_MIPMAPCOUNT: u32 = 0x2_0000; +const DDSD_LINEARSIZE: u32 = 0x8_0000; +const DDSCAPS_COMPLEX: u32 = 0x8; +const DDSCAPS_TEXTURE: u32 = 0x1000; +const DDSCAPS_MIPMAP: u32 = 0x40_0000; + +fn encode_err(reason: impl std::fmt::Display) -> CodecError { + CodecError::encode(Format::Dds, reason) +} + +/// How a written texture stores its pixels. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Store { + /// 8-bit luminance. + Luminance, + /// 8-bit luminance, then 8-bit alpha. + LuminanceAlpha, + /// Blue, green, red and an unused byte (written as 255, so a reader that takes it for + /// alpha still sees an opaque texture). + Bgrx, + Bgra, + Bc1, + Bc2, + Bc3, +} + +impl Store { + /// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it. + fn new(compression: DdsCompression, layout: ChannelLayout) -> Result { + Ok(match (compression, layout) { + (DdsCompression::None, ChannelLayout::Gray) => Store::Luminance, + (DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha, + (DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx, + (DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra, + (DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1, + (DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2, + (DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3, + (_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))), + }) + } + + /// Bytes per pixel, or per 4×4 block for a block compression. + fn unit(self) -> usize { + match self { + Store::Luminance => 1, + Store::LuminanceAlpha => 2, + Store::Bgrx | Store::Bgra => 4, + Store::Bc1 => 8, + Store::Bc2 | Store::Bc3 => 16, + } + } + + fn is_block(self) -> bool { + matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3) + } + + /// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide. + fn row_bytes(self, width: usize) -> Option { + if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) } + } + + fn level_bytes(self, width: usize, height: usize) -> Option { + let rows = if self.is_block() { height.div_ceil(4) } else { height }; + self.row_bytes(width)?.checked_mul(rows) + } + + /// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks. + fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) { + match self { + Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]), + Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]), + Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]), + Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]), + Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]), + Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]), + Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]), + } + } +} + +/// Levels in a full mip chain: the larger side halves down to 1. +fn mip_levels(width: u32, height: u32) -> u32 { + 32 - width.max(height).max(1).leading_zeros() +} + +fn half((w, h): (usize, usize)) -> (usize, usize) { + ((w / 2).max(1), (h / 2).max(1)) +} + +pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = image.converted(plan.layout, SampleType::U8); + let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?; + let store = Store::new(opts.dds.compression, img.layout())?; + let (width, height) = img.dimensions(); + let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 }; + let top = (width as usize, height as usize); + // Every level's size, checked before anything is allocated. + let mut sizes = Vec::new(); + let mut size = top; + for _ in 0..levels { + sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?); + size = half(size); + } + let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?; + let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?; + let mut out = crate::options::alloc_zeroed::(total)?; + out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header); + + let channels = img.layout().channels(); + let mut level = Cow::Borrowed(pixels); + let (mut size, mut at) = (top, HEADER); + for (i, &bytes) in sizes.iter().enumerate() { + let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?; + write_level(store, &level, size, dst)?; + at += bytes; + if i + 1 < sizes.len() { + let next = half(size); + level = Cow::Owned(downsample(&level, size, next, channels)?); + size = next; + } + } + Ok(out) +} + +/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes. +fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> { + let mut b = [0u8; HEADER]; + let mut put = |at: usize, v: u32| { + if let Some(d) = b.get_mut(at..at + 4) { + d.copy_from_slice(&v.to_le_bytes()); + } + }; + // Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size"). + let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) }; + let pitch_or_size = + pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?; + let mipmapped = levels > 1; + put(4, 124); + put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 }); + put(12, height); + put(16, width); + put(20, pitch_or_size); + put(28, levels); + let (flags, fourcc, bits, masks) = store.pixel_format(); + put(76, 32); + put(80, flags); + put(84, u32::from_le_bytes(fourcc)); + put(88, bits); + for (i, mask) in masks.into_iter().enumerate() { + put(92 + 4 * i, mask); + } + put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 }); + b[..4].copy_from_slice(b"DDS "); + Ok(b) +} + +/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets. +fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row)); + } + #[cfg(target_arch = "wasm32")] + for (i, row) in out.chunks_mut(len.max(1)).enumerate() { + f(i, row); + } +} + +/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`. +fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> { + let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?; + match store { + Store::Luminance | Store::LuminanceAlpha => { + // The image's gray (and alpha) samples, in the same order. + let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?; + dst.copy_from_slice(src); + } + Store::Bgrx | Store::Bgra => { + let channels = if store == Store::Bgra { 4 } else { 3 }; + rows(dst, row_bytes, |y, row| { + let src = pixels.get(y * width * channels..).unwrap_or_default(); + for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) { + let byte = |i: usize| s.get(i).copied().unwrap_or(255); + *d = [byte(2), byte(1), byte(0), byte(3)]; + } + }); + } + Store::Bc1 | Store::Bc2 | Store::Bc3 => { + let (texels, _) = pixels.as_chunks::<4>(); + rows(dst, row_bytes, |by, row| { + for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() { + let (block, inside) = gather(texels, (width, height), bx, by); + let encoded: &[u8] = match store { + Store::Bc1 => &dds_bc::encode_bc1(&block, inside), + Store::Bc2 => &dds_bc::encode_bc2(&block, inside), + _ => &dds_bc::encode_bc3(&block, inside), + }; + for (d, s) in d.iter_mut().zip(encoded) { + *d = *s; + } + } + }); + } + } + Ok(()) +} + +/// Block (`bx`, `by`)'s texels, and which of them lie inside the image. +fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) { + let mut block = [[0u8; 4]; 16]; + let mut inside: Mask = 0; + for (t, texel) in block.iter_mut().enumerate() { + let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4); + if x < width + && y < height + && let Some(p) = texels.get(y * width + x) + { + *texel = *p; + inside |= 1 << t; + } + } + (block, inside) +} + +/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or +/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped. +fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result, CodecError> { + let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?; + let mut dst = crate::options::alloc_zeroed::(len)?; + let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 }; + rows(&mut dst, nw * channels, |y, row| { + let ys = span(y, nh, h); + for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() { + let xs = span(x, nw, w); + for (c, v) in px.iter_mut().enumerate() { + let (mut sum, mut n) = (0u32, 0u32); + for sy in ys.clone() { + for sx in xs.clone() { + if let Some(&s) = src.get((sy * w + sx) * channels + c) { + sum += u32::from(s); + n += 1; + } + } + } + *v = ((sum + n / 2) / n.max(1)) as u8; + } + } + }); + Ok(dst) +} diff --git a/crates/codecs/src/codecs/dds_bc.rs b/crates/codecs/src/codecs/dds_bc.rs new file mode 100644 index 0000000000..bbd55b4bf3 --- /dev/null +++ b/crates/codecs/src/codecs/dds_bc.rs @@ -0,0 +1,368 @@ +//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression +//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D +//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses. +//! +//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis, +//! refines them by least squares, and scores every candidate against the exact palette the +//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows. + +/// The 16 texels of a 4×4 block, row by row, as RGBA. +pub(crate) type Block = [[u8; 4]; 16]; + +/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is +/// not a multiple of four have texels outside the image. +pub(crate) type Mask = u16; + +pub(crate) fn rgb565(c: u16) -> [u8; 3] { + let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; + [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] +} + +/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every +/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and +/// transparent black. +pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] { + let (a, b) = (rgb565(c0), rgb565(c1)); + let mix = |wa: u32, wb: u32| { + let d = wa + wb; + let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; + [at(0), at(1), at(2), 255] + }; + if four { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] + } else { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] + } +} + +/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four +/// interpolated at fifths followed by 0 and 255. +pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] { + let (a0, a1) = (u32::from(a0), u32::from(a1)); + let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; + if a0 > a1 { + for (k, v) in palette.iter_mut().enumerate().skip(2) { + let k = k as u32; + *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; + } + } else { + for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { + let k = k as u32; + *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; + } + } + palette.map(|v| v as u8) +} + +/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the +/// three-colour mode; an opaque block takes whichever mode fits its colours better. +pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] { + let transparent = select(block, inside, |t| t[3] < 128); + colour_block(block, inside & !transparent, transparent, true) +} + +/// A BC2 block: four bits of alpha per texel, then a four-colour block. +pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] { + let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t)); + join(bits.to_le_bytes(), colour_block(block, inside, 0, false)) +} + +/// A BC3 block: an interpolated alpha block, then a four-colour block. +pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] { + join(alpha_block(block, inside), colour_block(block, inside, 0, false)) +} + +fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] { + std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0)) +} + +/// The texels of `inside` for which `f` holds. +fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask { + block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t) +} + +fn rgb_of(texel: &[u8; 4]) -> [f32; 3] { + [f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])] +} + +/// RGB565 of a colour, rounding each channel to the nearest level. +fn to565(c: [f32; 3]) -> u16 { + let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16; + q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0) +} + +fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 { + (0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum() +} + +/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the +/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and +/// BC3 always decode four colours. +fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] { + let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect(); + if colours.is_empty() { + // Nothing visible: every texel on the transparent index (or index 0 of black). + let indices = if transparent != 0 { u32::MAX } else { 0 }; + return pack(0, 0, indices); + } + let (lo, hi) = principal_endpoints(&colours); + let mut candidates = vec![(to565(lo), to565(hi))]; + let modes: &[bool] = if transparent != 0 { + &[false] + } else if bc1 { + &[true, false] + } else { + &[true] + }; + let mut best: Option<(u32, [u8; 8])> = None; + // Each refinement starts from the best block so far: its indices give the least-squares + // endpoints, which become one more candidate. + for round in 0..3 { + for &(e0, e1) in &candidates { + for &four in modes { + let (bytes, error) = fit(block, opaque, transparent, e0, e1, four); + if best.is_none_or(|(e, _)| error < e) { + best = Some((error, bytes)); + } + } + } + let Some((error, bytes)) = best else { break }; + if error == 0 || round == 2 { + break; + } + candidates = least_squares(block, opaque, &bytes).into_iter().collect(); + if candidates.is_empty() { + break; + } + } + best.map_or([0; 8], |(_, bytes)| bytes) +} + +/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three) +/// and picks each texel's nearest palette colour. Returns the block and its squared error. +fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) { + let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) }; + // Equal endpoints can't be in four-colour order; the palette is then one colour either way. + let palette = colour_palette(c0, c1, four && c0 > c1); + let choices = if four && c0 > c1 { 4 } else { 3 }; + let mut indices = 0u32; + let mut error = 0u32; + for (t, texel) in block.iter().enumerate() { + let index = if transparent >> t & 1 != 0 { + 3 + } else if opaque >> t & 1 != 0 { + let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0)); + error += d; + i as u32 + } else { + 0 + }; + indices |= index << (2 * t); + } + (pack(c0, c1, indices), error) +} + +fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] { + let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes()); + [c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]] +} + +/// The ends of the colours' spread along their principal axis (the covariance's dominant +/// eigenvector, by power iteration), clamped to the RGB cube. +fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) { + let n = colours.len().max(1) as f32; + let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::() / n); + let mut cov = [[0f32; 3]; 3]; + for c in colours { + let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]]; + for (i, row) in cov.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v += d[i] * d[j]; + } + } + } + let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]); + let iterate = |start: usize| { + let mut axis = [0f32; 3]; + axis[start] = 1.0; + for _ in 0..8 { + let next = times(axis); + let len = next.iter().map(|v| v * v).sum::().sqrt(); + if !len.is_normal() { + break; + } + axis = next.map(|v| v / len); + } + axis + }; + // From each channel's axis, keeping the one the colours spread along most: a channel that + // varies on its own is already an eigenvector, so a single start there would never leave it + // even when the others vary more together. + let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::(); + let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]); + let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2]; + let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t))); + let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0)); + if lo > hi { (mean, mean) } else { (at(lo), at(hi)) } +} + +/// The endpoints that minimise the squared error for the indices `bytes` already chose: each +/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per +/// channel. `None` when the indices don't constrain both endpoints. +fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> { + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + let four = c0 > c1; + // How much of c0 each index holds. + let weight = |i: u32| match (four, i) { + (_, 0) => Some(1.0f32), + (_, 1) => Some(0.0), + (true, 2) => Some(2.0 / 3.0), + (true, _) => Some(1.0 / 3.0), + (false, 2) => Some(0.5), + (false, _) => None, + }; + let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32); + let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]); + for (t, texel) in block.iter().enumerate() { + if opaque >> t & 1 == 0 { + continue; + } + let Some(a) = weight(indices >> (2 * t) & 3) else { continue }; + let b = 1.0 - a; + let c = rgb_of(texel); + aa += a * a; + ab += a * b; + bb += b * b; + for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) { + *x0 += a * v; + *x1 += b * v; + } + } + let det = aa * bb - ab * ab; + if det.abs() < 1e-3 { + return None; + } + let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det); + let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det); + Some((to565(e0), to565(e1))) +} + +/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six +/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better. +fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] { + let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]); + let range = |values: &mut dyn Iterator| values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a))))); + let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0)); + // Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too. + let eight = alpha_fit(block, inside, hi, lo); + let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0)); + let six = alpha_fit(block, inside, mid_lo, mid_hi); + if six.1 < eight.1 { six.0 } else { eight.0 } +} + +fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) { + let palette = alpha_palette(a0, a1); + let mut bits = 0u64; + let mut error = 0u32; + for (t, texel) in block.iter().enumerate() { + if inside >> t & 1 == 0 { + continue; + } + let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0)); + error += d; + bits |= (i as u64) << (3 * t); + } + let b = bits.to_le_bytes(); + ([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block { + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + let palette = colour_palette(c0, c1, c0 > c1 || !bc1); + let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) + } + + #[test] + fn solid_blocks_land_on_the_nearest_565_colour() { + for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] { + let block = [colour; 16]; + let out = decode_colour(&encode_bc1(&block, u16::MAX), true); + for texel in out { + assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}"); + } + } + } + + #[test] + fn two_colour_blocks_are_exact_when_the_colours_are_565() { + let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]); + let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b }); + for bc1 in [true, false] { + let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) }; + assert_eq!(decode_colour(&bytes, bc1), block); + } + } + + #[test] + fn bc1_punches_through_texels_under_half_alpha() { + let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] }); + let bytes = encode_bc1(&block, u16::MAX); + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + assert!(c0 <= c1, "three-colour mode"); + let out = decode_colour(&bytes, true); + assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}"); + } + + #[test] + fn four_colour_blocks_keep_c0_above_c1() { + // A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but + // older decoders read c0 <= c1 as three colours, so the encoder never writes that. + let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]); + let bytes = colour_block(&block, u16::MAX, 0, false); + assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]])); + } + + #[test] + fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() { + // 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly. + let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]); + let bytes = alpha_block(&block, u16::MAX); + assert!(bytes[0] <= bytes[1], "six-value mode"); + let palette = alpha_palette(bytes[0], bytes[1]); + let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]); + for (t, texel) in block.iter().enumerate() { + let got = palette[(bits >> (3 * t)) as usize & 7]; + assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]); + } + } + + #[test] + fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() { + // Red alternates on its own (variance 64); green and blue alternate together (variance 49 + // each, 98 along their shared axis), uncorrelated with red. Starting on red alone would + // keep red's axis and flatten green and blue; the block must do better than its bounding box. + let block: Block = std::array::from_fn(|t| { + let gb = if (t / 2) % 2 == 0 { 121 } else { 135 }; + [if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255] + }); + let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::(); + let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true); + let ours = error(&colour_block(&block, u16::MAX, 0, false)); + assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}"); + } + + #[test] + fn texels_outside_the_image_are_ignored() { + // Only the top-left texel is inside: its colour alone decides the endpoints. + let mut block: Block = [[0, 255, 0, 255]; 16]; + block[0] = [255, 0, 255, 255]; + let out = decode_colour(&encode_bc1(&block, 1), true); + assert_eq!(out[0], [255, 0, 255, 255]); + } +} diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 29f3762ebc..4fe9def532 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -341,15 +341,17 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res return Err(err(format!("chunk declares part {part}, expected {header_index}"))); } } - // Block coordinates → the block's global pixel range and its slot. - let (block_pixels, slot, row_w): (Vec, usize, usize) = match &header.blocks { + // Block coordinates → the block's pixel bounds and its slot. The per-pixel index is + // built only after the chunk's declared payload sizes are validated below, so a + // truncated or lying chunk cannot drive a large index allocation first. + let (block_rows, block_cols, slot, row_w, scanline_row): (std::ops::Range, std::ops::Range, usize, usize, bool) = match &header.blocks { BlockDescription::ScanLines => { let y = i32(&mut cur)?; let row = match y.checked_sub(y0) { Some(d) if d >= 0 && (d as usize) < h => d as usize, _ => return Err(err(format!("scan line block y={y} is outside the data window"))), }; - ((0..w).map(|x| row * w + x).collect(), row, w) + (row..row + 1, 0..w, row, w, true) } BlockDescription::Tiles(_) => { let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?); @@ -362,16 +364,11 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h); let rows = (by + tile_h).min(h); let cols = (bx + tile_w).min(w); - let mut px = Vec::with_capacity((rows - by) * (cols - bx)); - for yy in by..rows { - for xx in bx..cols { - px.push(yy.saturating_mul(w).saturating_add(xx)); - } - } let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize; - (px, slot, cols - bx) + (by..rows, bx..cols, slot, cols - bx, false) } }; + let block_len = block_rows.len().saturating_mul(block_cols.len()); if seen.get(slot).copied().unwrap_or(true) { return Err(err(format!("two chunks cover block {slot}"))); } @@ -386,6 +383,20 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); } + // The chunk's payload is present; now materialize the block's global pixel index. For + // scan lines this is one row; for tiles the clipped tile rectangle. + let block_pixels: Vec = if scanline_row { + let row = block_rows.start; + (0..w).map(|x| row * w + x).collect() + } else { + let mut px = Vec::with_capacity(block_len); + for yy in block_rows.clone() { + for xx in block_cols.clone() { + px.push(yy.saturating_mul(w).saturating_add(xx)); + } + } + px + }; let what = "the sample-count table"; let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?; @@ -552,7 +563,7 @@ fn header_end(bytes: &[u8]) -> Result { /// into straight alpha at the end. Without any alpha channel the front-most sample wins, /// opaque. Z, ZBack and other deep channels influence the flat result only through the /// ordering; volume samples (with ZBack) are approximated as point samples. -pub(crate) fn flatten(deep: &DeepImage) -> Result { +pub(crate) fn flatten(deep: &DeepImage, limits: &Limits) -> Result { let npx = deep.width as usize * deep.height as usize; let has = |n: &str| deep.channel(n).is_some(); let rgb = has("R") && has("G") && has("B"); @@ -569,6 +580,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part")); }; let nc = layout.channels(); + // Admit the flattened output at its true size: nc f32 samples per pixel. The deep decode + // admitted the data window at four bytes per pixel, before the flattened channel layout + // was known, so re-check here with the actual layout and f32 sample size. + limits.check_bytes(deep.width, deep.height, nc as u64 * 4)?; let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) }; let (r, g, b) = (samples("R"), samples("G"), samples("B")); let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB")); @@ -665,6 +680,10 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { // pads to the stage at its offset, transparent elsewhere (limits validated the size). let mut img = match deep.stage { Some((sw, sh)) => { + // The display stage can be larger than the data window; admit it at the real f32 + // layout size before allocating, since the earlier deep-decode check used four + // bytes per pixel and did not know the flattened channel count. + limits.check_bytes(sw, sh, nc as u64 * 4)?; let mut padded = vec![0f32; sw as usize * sh as usize * nc]; let row_len = deep.width as usize * nc; let dx = deep.origin.0.max(0) as usize; diff --git a/crates/codecs/src/codecs/dicom.rs b/crates/codecs/src/codecs/dicom.rs new file mode 100644 index 0000000000..ea2674dae7 --- /dev/null +++ b/crates/codecs/src/codecs/dicom.rs @@ -0,0 +1,517 @@ +//! DICOM (medical imaging) reader, uncompressed transfer syntaxes only. +//! +//! Supports what a viewer needs from a single-frame image: MONOCHROME1/2 (gray) and RGB or +//! YBR_FULL, 8- and 16-bit samples, interleaved or planar, both byte orders and explicit or +//! implicit VR. Undefined-length sequences outside the pixel data are skipped (clinical files +//! contain them); compressed transfer syntaxes, encapsulated pixel data and other photometric +//! interpretations are refused with a clear error. Read-only in this crate. + +use std::collections::HashMap; + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Dicom; +const PREAMBLE: usize = 132; // 128 zero bytes + "DICM" +const UNDEFINED: u32 = 0xFFFF_FFFF; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +/// A DICOM file begins with a 128-byte preamble and the `DICM` magic. +pub(crate) fn is_dicom(b: &[u8]) -> bool { + b.get(128..132) == Some(b"DICM") +} + +#[derive(Clone, Copy)] +struct Endian { + big: bool, +} + +/// The transfer syntax: byte order and whether value representations are explicit. +#[derive(Clone, Copy)] +struct Syntax { + endian: Endian, + explicit: bool, +} + +/// The value representations whose length field is 2 reserved bytes + `u32` in explicit VR. +fn long_vr(vr: &[u8]) -> bool { + matches!(vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN" | b"OD" | b"OL" | b"OV" | b"UC" | b"UR") +} + +/// A cursor over the byte stream. +struct Cur<'a> { + b: &'a [u8], + pos: usize, + big: bool, +} + +impl<'a> Cur<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(s) + } + fn u16(&mut self) -> Option { + let c = self.take(2)?; + Some(if self.big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) + } + fn u32(&mut self) -> Option { + let c = self.take(4)?; + Some(if self.big { u32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { u32::from_le_bytes([c[0], c[1], c[2], c[3]]) }) + } + /// Skip `len` bytes, refusing to run past the buffer. + fn skip(&mut self, len: usize) -> Result<(), CodecError> { + self.pos = self.pos.checked_add(len).filter(|p| *p <= self.b.len()).ok_or_else(|| bad("a value runs past the end of the file"))?; + Ok(()) + } +} + +/// Read one element's tag and value length, leaving the cursor at the value. Item and delimiter +/// tags (group `FFFE`) carry a `u32` length with no VR even under explicit VR. +fn read_header(c: &mut Cur, syn: &Syntax) -> Result<(u16, u16, [u8; 2], u32), CodecError> { + let group = c.u16().ok_or_else(|| bad("truncated element tag"))?; + let elem = c.u16().ok_or_else(|| bad("truncated element tag"))?; + let (vr, len) = if group == 0xFFFE { + ([0, 0], c.u32().ok_or_else(|| bad("truncated item length"))?) + } else if syn.explicit { + let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?; + let v = [vr[0], vr[1]]; + let len = if long_vr(&v) { + c.take(2).ok_or_else(|| bad("truncated VR"))?; // reserved + c.u32().ok_or_else(|| bad("truncated length"))? + } else { + u32::from(c.u16().ok_or_else(|| bad("truncated length"))?) + }; + (v, len) + } else { + ([0, 0], c.u32().ok_or_else(|| bad("truncated length"))?) + }; + Ok((group, elem, vr, len)) +} + +/// Skip an undefined-length sequence, ending just after its `FFFE,E0DD` delimiter. Nesting is +/// tracked through undefined-length items and nested sequences; everything else is skipped by +/// its length. Returns the position after the delimiter. +fn skip_undefined(c: &mut Cur, syn: &Syntax) -> Result<(), CodecError> { + let mut depth = 1u32; + while depth > 0 { + let (group, elem, _, len) = read_header(c, syn)?; + // Item (E00D) and sequence (E0DD) delimiters close one undefined length; they carry no + // content of their own. + if group == 0xFFFE && matches!(elem, 0xE00D | 0xE0DD) { + depth -= 1; + continue; + } + if len == UNDEFINED { + depth += 1; + } else { + c.skip(len as usize)?; + } + } + Ok(()) +} + +/// Walk the elements from `start`, calling `f` with each `(group, element, value)`. An +/// undefined-length sequence outside the pixel data is skipped; encapsulated (undefined-length) +/// pixel data is refused. Returns the number of bytes walked. +fn elements(b: &[u8], start: usize, syn: &Syntax, mut f: impl FnMut(u16, u16, &Elem) -> Result<(), CodecError>) -> Result { + let mut c = Cur { b, pos: start, big: syn.endian.big }; + loop { + if c.pos >= b.len() { + return Ok(c.pos - start); + } + let (group, elem, vr, len) = read_header(&mut c, syn)?; + if len == UNDEFINED { + if (group, elem) == (0x7FE0, 0x0010) { + return Err(CodecError::unsupported(F, "encapsulated (compressed) pixel data")); + } + skip_undefined(&mut c, syn)?; + continue; + } + let len = len as usize; + let start = c.pos; + if b.len() < start.saturating_add(len) { + return Err(bad("an element value runs past the end of the file")); + } + f(group, elem, &Elem { vr, start, len })?; + c.pos = start + len; + } +} + +/// A parsed element: its VR and the byte range of its value. +struct Elem { + vr: [u8; 2], + start: usize, + len: usize, +} + +fn u16_val(b: &[u8], e: &Elem, big: bool) -> Option { + let c = b.get(e.start..e.start.checked_add(2)?)?; + Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) +} + +/// The transfer syntax (byte order and explicit/implicit VR) and the data-set start. The file +/// meta group is always explicit VR little-endian; the data set follows its `(0002,0010)` +/// TransferSyntaxUID. +fn transfer_syntax(b: &[u8]) -> Result<(Syntax, usize), CodecError> { + let meta = Syntax { endian: Endian { big: false }, explicit: true }; + let mut ts: Option = None; + let mut data_start = b.len(); + let mut c = Cur { b, pos: PREAMBLE, big: false }; + while c.pos + 8 <= b.len() { + let save = c.pos; + let (group, elem, _, len) = read_header(&mut c, &meta)?; + if group != 0x0002 { + // A non-meta group begins the data set. + data_start = save; + break; + } + if len == UNDEFINED { + return Err(CodecError::unsupported(F, "undefined-length meta element")); + } + let start = c.pos; + if b.len() < start.saturating_add(len as usize) { + return Err(bad("a meta element runs past the end of the file")); + } + if elem == 0x0010 { + ts = Some(String::from_utf8_lossy(&b[start..start + len as usize]).trim_end_matches(['\0', ' ']).to_string()); + } + c.pos = start + len as usize; + } + let syn = match ts.as_deref() { + Some("1.2.840.10008.1.2") => Syntax { endian: Endian { big: false }, explicit: false }, + Some("1.2.840.10008.1.2.1") => Syntax { endian: Endian { big: false }, explicit: true }, + Some("1.2.840.10008.1.2.2") => Syntax { endian: Endian { big: true }, explicit: true }, + Some(other) => return Err(CodecError::unsupported(F, format!("transfer syntax {other} (only uncompressed is read)"))), + // No meta: the historical default is implicit VR little-endian. + None => Syntax { endian: Endian { big: false }, explicit: false }, + }; + Ok((syn, data_start)) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !is_dicom(b) { + return Err(bad("not a DICOM file (no DICM magic)")); + } + let (syn, start) = transfer_syntax(b)?; + let mut tags: HashMap<(u16, u16), Elem> = HashMap::new(); + elements(b, start, &syn, |g, e, el| { + // Later duplicates must not silently override the first (DICOM forbids duplicates). + tags.entry((g, e)).or_insert_with(|| Elem { vr: el.vr, start: el.start, len: el.len }); + Ok(()) + })?; + let big = syn.endian.big; + let get_u16 = |tag: (u16, u16)| tags.get(&tag).and_then(|e| u16_val(b, e, big)); + let rows = u32::from(get_u16((0x0028, 0x0010)).ok_or_else(|| bad("missing Rows (0028,0010)"))?); + let cols = u32::from(get_u16((0x0028, 0x0011)).ok_or_else(|| bad("missing Columns (0028,0011)"))?); + let samples = u32::from(get_u16((0x0028, 0x0002)).unwrap_or(1)); + let bits = get_u16((0x0028, 0x0100)).ok_or_else(|| bad("missing BitsAllocated (0028,0100)"))?; + let bits_stored = get_u16((0x0028, 0x0101)).unwrap_or(bits); + let high_bit = get_u16((0x0028, 0x0102)).unwrap_or(bits_stored.saturating_sub(1)); + if u32::from(high_bit) >= u32::from(bits) { + return Err(bad("HighBit is outside the allocated bits")); + } + let signed = get_u16((0x0028, 0x0103)).unwrap_or(0) == 1; + let planar = get_u16((0x0028, 0x0006)).unwrap_or(0); + let photometric = tags + .get(&(0x0028, 0x0004)) + .and_then(|e| b.get(e.start..e.start.checked_add(e.len)?)) + .map(|v| String::from_utf8_lossy(v).trim_end_matches(['\0', ' ']).to_string()) + .ok_or_else(|| bad("missing PhotometricInterpretation (0028,0004)"))?; + let pixel = tags.get(&(0x7FE0, 0x0010)).ok_or_else(|| CodecError::unsupported(F, "no pixel data (7FE0,0010)"))?; + if !matches!(bits, 8 | 16) || !matches!(bits_stored, 1..=16) { + return Err(CodecError::unsupported(F, format!("{bits}-bit allocated / {bits_stored}-bit stored samples"))); + } + let mono = matches!(photometric.as_str(), "MONOCHROME1" | "MONOCHROME2"); + let rgb = photometric == "RGB"; + let ybr = photometric == "YBR_FULL"; + if !(mono || rgb || ybr) { + return Err(CodecError::unsupported(F, format!("photometric interpretation {photometric}"))); + } + // Mono stores one sample; RGB and YBR_FULL store three. Any other count would index past the + // pixel data, so it is refused rather than misread. + let want_samples = if mono { 1 } else { 3 }; + if samples != want_samples { + return Err(CodecError::unsupported(F, format!("{samples} samples per pixel with {photometric}"))); + } + if ybr && bits != 8 { + return Err(CodecError::unsupported(F, format!("{bits}-bit {photometric} (8-bit only)"))); + } + let layout = if mono { ChannelLayout::Gray } else { ChannelLayout::Rgb }; + let out_sample = if bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(cols, rows, layout, out_sample)?; + + let pixels = (cols as usize).checked_mul(rows as usize).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + let n = pixels.checked_mul(samples as usize).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + let bytes_per = if bits == 16 { 2usize } else { 1 }; + let need = n.checked_mul(bytes_per).ok_or_else(|| CodecError::LimitExceeded("pixel data too large".into()))?; + let data = b + .get(pixel.start..pixel.start.checked_add(need).ok_or_else(|| CodecError::LimitExceeded("pixel data too large".into()))?) + .ok_or_else(|| bad("pixel data is truncated"))?; + let out_channels = layout.channels(); + let out_len = pixels.checked_mul(out_channels).and_then(|k| k.checked_mul(bytes_per)).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + let mut out = Vec::new(); + out.try_reserve_exact(out_len).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + // Raw sample of channel `ch` at pixel `p`, honouring the planar configuration. + let sample_at = |p: usize, ch: usize| -> Option { + let idx = if planar == 1 { ch.checked_mul(pixels)?.checked_add(p)? } else { p.checked_mul(samples as usize)?.checked_add(ch)? }; + let off = idx.checked_mul(bytes_per)?; + if bits == 16 { + let c = data.get(off..off.checked_add(2)?)?; + Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }.into()) + } else { + Some(u32::from(*data.get(off)?)) + } + }; + let stored_max = f64::from((1u32 << bits_stored) - 1); + // Extract the `bits_stored` samples (the least significant one at `high_bit`) and scale to + // [0, 1]; signed values are shifted up by half a range first. `high_bit` is 1-based here. + let shift = (u32::from(high_bit) + 1).saturating_sub(u32::from(bits_stored)); + let mask = (1u32 << bits_stored) - 1; + let half = 1u32 << (bits_stored - 1); + let norm = move |raw: u32| -> f64 { + let v = (raw >> shift) & mask; + // Signed: flip the sign bit to map [-2^(b-1), 2^(b-1)-1] onto [0, 2^b-1]. + let v = if signed { v ^ half } else { v }; + (f64::from(v) / stored_max).clamp(0.0, 1.0) + }; + let q = |x: f64| -> u16 { (x * 65535.0).round() as u16 }; + let push = |out: &mut Vec, x: f64| { + if bits == 8 { + out.push((x * 255.0).round() as u8); + } else { + out.extend_from_slice(&q(x).to_ne_bytes()); + } + }; + for p in 0..pixels { + let s = |ch: usize| sample_at(p, ch).ok_or_else(|| bad("pixel data is shorter than the image")); + if mono { + let x = norm(s(0)?); + push(&mut out, if photometric == "MONOCHROME1" { 1.0 - x } else { x }); + } else if rgb { + for ch in 0..3 { + push(&mut out, norm(s(ch)?)); + } + } else { + // YBR_FULL (8-bit): BT.601 full-range to RGB. + let y = f64::from(s(0)?); + let cb = f64::from(s(1)?) - 128.0; + let cr = f64::from(s(2)?) - 128.0; + let r = (y + 1.402 * cr).clamp(0.0, 255.0); + let g = (y - 0.344_136 * cb - 0.714_136 * cr).clamp(0.0, 255.0); + let bl = (y + 1.772 * cb).clamp(0.0, 255.0); + for v in [r, g, bl] { + push(&mut out, v / 255.0); + } + } + } + Image::from_raw(cols, rows, layout, out_sample, out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build the file meta group (explicit VR little-endian), including the given extra + /// `(element, ascii)` pairs before the TransferSyntaxUID. + fn meta(b: &mut Vec, ts: &str, extra: &[(u16, &str)]) { + let ui = |e: u16, v: &str, b: &mut Vec| { + let mut s = v.as_bytes().to_vec(); + if s.len() % 2 == 1 { + s.push(0); + } + b.extend_from_slice(&0x0002u16.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + b.extend_from_slice(b"UI"); + b.extend_from_slice(&(s.len() as u16).to_le_bytes()); + b.extend_from_slice(&s); + }; + for (e, v) in extra { + ui(*e, v, b); + } + ui(0x0010, ts, b); + } + + fn us(b: &mut Vec, g: u16, e: u16, v: u16, explicit: bool) { + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + if explicit { + b.extend_from_slice(b"US"); + b.extend_from_slice(&2u16.to_le_bytes()); + } else { + b.extend_from_slice(&2u32.to_le_bytes()); + } + b.extend_from_slice(&v.to_le_bytes()); + } + + fn cs(b: &mut Vec, g: u16, e: u16, v: &str, explicit: bool) { + let mut s = v.as_bytes().to_vec(); + if s.len() % 2 == 1 { + s.push(0); + } + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + if explicit { + b.extend_from_slice(b"CS"); + b.extend_from_slice(&(s.len() as u16).to_le_bytes()); + } else { + b.extend_from_slice(&(s.len() as u32).to_le_bytes()); + } + b.extend_from_slice(&s); + } + + /// A DICOM with the given transfer syntax UID and data-set encoding. + #[allow(clippy::too_many_arguments)] + fn synth(ts: &str, explicit: bool, big: bool, planar: u16, rows: u16, cols: u16, samples: u16, bits: u16, photometric: &str, pixels: &[u8]) -> Vec { + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + // One extra meta element before the syntax, so a reader that only looks at the last + // group-0002 element takes the wrong one. + meta(&mut b, ts, &[(0x0002, "1.2.840.10008.5.1.4.1.1.7")]); + let _ = big; + us(&mut b, 0x0028, 0x0010, rows, explicit); + us(&mut b, 0x0028, 0x0011, cols, explicit); + us(&mut b, 0x0028, 0x0002, samples, explicit); + us(&mut b, 0x0028, 0x0006, planar, explicit); + cs(&mut b, 0x0028, 0x0004, photometric, explicit); + us(&mut b, 0x0028, 0x0100, bits, explicit); + us(&mut b, 0x0028, 0x0101, bits, explicit); + us(&mut b, 0x0028, 0x0102, bits - 1, explicit); + us(&mut b, 0x0028, 0x0103, 0, explicit); + b.extend_from_slice(&0x7FE0u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + if explicit { + b.extend_from_slice(b"OB"); + b.extend_from_slice(&0u16.to_le_bytes()); + } + b.extend_from_slice(&(pixels.len() as u32).to_le_bytes()); + b.extend_from_slice(pixels); + b + } + + #[test] + fn decodes_8bit_monochrome() { + let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (2, 2, ChannelLayout::Gray, SampleType::U8)); + assert_eq!(img.data(), &[0, 51, 102, 255]); + } + + #[test] + fn decodes_16bit_monochrome_and_scales() { + let px: Vec = [0u16, 32768, 65535].iter().flat_map(|v| v.to_le_bytes()).collect(); + let b = synth("1.2.840.10008.1.2.1", true, false, 0, 3, 1, 1, 16, "MONOCHROME2", &px); + let img = decode(&b, &Limits::default()).unwrap(); + let v = img.to_u16_samples().unwrap(); + assert_eq!(v[0], 0); + assert!((i32::from(v[1]) - 32768).abs() <= 1); + assert_eq!(v[2], 65535); + } + + #[test] + fn monochrome1_is_inverted() { + let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 1, 1, 8, "MONOCHROME1", &[0, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[255, 0]); + } + + #[test] + fn decodes_rgb_interleaved_and_planar() { + // 2 pixels: (10,20,30) and (40,50,60). + let interleaved = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 2, 3, 8, "RGB", &[10, 20, 30, 40, 50, 60]); + let img = decode(&interleaved, &Limits::default()).unwrap(); + assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgb, &[10u8, 20, 30, 40, 50, 60][..])); + // Planar 0: R plane, then G, then B. + let planar = synth("1.2.840.10008.1.2.1", true, false, 1, 1, 2, 3, 8, "RGB", &[10, 40, 20, 50, 30, 60]); + let img = decode(&planar, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[10, 20, 30, 40, 50, 60]); + } + + #[test] + fn decodes_ybr_full_to_rgb() { + // White (Y=128, Cb=Cr=128) stays neutral grey. + let b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 3, 8, "YBR_FULL", &[128, 128, 128]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgb); + let d = img.data(); + assert!(d[0].abs_diff(d[1]) <= 1 && d[1].abs_diff(d[2]) <= 1, "{d:?}"); + } + + #[test] + fn decodes_implicit_vr() { + let b = synth("1.2.840.10008.1.2", false, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.data(), &[0, 51, 102, 255]); + } + + #[test] + fn skips_an_undefined_length_sequence_outside_the_pixel_data() { + // Insert an undefined-length SQ (0008,2218) with one item, then a defined-length one, + // before the pixel data: the reader must skip both and still decode the image. + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + meta(&mut b, "1.2.840.10008.1.2.1", &[]); + for (g, e, v) in [ + (0x0028, 0x0010, 1u16), + (0x0028, 0x0011, 1), + (0x0028, 0x0002, 1), + (0x0028, 0x0100, 8), + (0x0028, 0x0101, 8), + (0x0028, 0x0102, 7), + (0x0028, 0x0103, 0), + ] { + us(&mut b, g, e, v, true); + } + cs(&mut b, 0x0028, 0x0004, "MONOCHROME2", true); + // Undefined-length sequence (0008,2211), one undefined-length item containing a nested + // defined-length sequence, then the delimiters. + b.extend_from_slice(&[0x08, 0x00, 0x11, 0x22, b'S', b'Q', 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF]); + b.extend_from_slice(&[0xFE, 0xFF, 0x00, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF]); // item, undefined + b.extend_from_slice(&[0x08, 0x00, 0x55, 0x22, b'S', b'Q', 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 1, 2, 3, 4]); // nested, defined + b.extend_from_slice(&[0xFE, 0xFF, 0x0D, 0xE0, 0x00, 0x00, 0x00, 0x00]); // item delimitation + b.extend_from_slice(&[0xFE, 0xFF, 0xDD, 0xE0, 0x00, 0x00, 0x00, 0x00]); // sequence delimitation + // Pixel data (defined length). + b.extend_from_slice(&0x7FE0u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + b.extend_from_slice(b"OB"); + b.extend_from_slice(&0u16.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.push(200); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[200]); + } + + #[test] + fn truncated_and_bad_is_error_not_panic() { + let b = synth("1.2.840.10008.1.2.1", true, false, 0, 2, 2, 1, 8, "MONOCHROME2", &[0, 1, 2, 3]); + for cut in [0, 4, 128, 132, 140, b.len() - 1] { + assert!(decode(&b[..cut.min(b.len())], &Limits::default()).is_err(), "cut {cut}"); + } + assert!(decode(b"not dicom", &Limits::default()).is_err()); + } + + #[test] + fn high_bit_outside_the_allocated_bits_is_an_error_not_a_panic() { + let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[7]); + let tag = [0x28, 0x00, 0x02, 0x01, b'U', b'S', 0x02, 0x00]; + let at = b.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len(); + b[at..at + 2].copy_from_slice(&200u16.to_le_bytes()); + assert!(decode(&b, &Limits::default()).is_err()); + } + + #[test] + fn rejects_compressed_transfer_syntax() { + let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[0]); + let uncompressed = b"1.2.840.10008.1.2.1\0"; + let compressed = b"1.2.840.10008.1.2.4\0"; // same length, a compressed family + let at = b.windows(uncompressed.len()).position(|w| w == uncompressed).expect("transfer syntax in the fixture"); + b[at..at + compressed.len()].copy_from_slice(compressed); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 091403d641..f93c79c928 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -21,6 +21,8 @@ //! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with //! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image //! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. +//! +//! Kodak Cineon (`.cin`), the format DPX grew from, is read and written here too (see its section). use crate::Format; use crate::error::CodecError; @@ -344,7 +346,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors // (the first carries the bits per pixel and the line/image dimensions). Image data is three // 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as -// DPX's 10-bit packing method A, so the unpacking is shared. +// DPX's 10-bit packing method A, so the unpacking and packing are shared. /// The Cineon magic number (`802A5FD7` hex), big-endian. pub(crate) fn is_cineon(b: &[u8]) -> bool { @@ -409,6 +411,81 @@ pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result Result, CodecError> { + let enc = |msg: String| CodecError::encode(Format::Cineon, msg); + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + // Channel designators: 0 for black and white, 1–3 for red, green and blue. + let designators: &[u8] = match img.layout() { + ChannelLayout::Gray => &[0], + ChannelLayout::Rgb => &[1, 2, 3], + l => return Err(enc(format!("Cineon cannot store {l:?}"))), + }; + let samples = img.to_u16_samples().ok_or_else(|| enc(format!("cannot write {:?} samples at 10 bits", img.sample_type())))?; + let codes: Vec = samples.into_iter().map(|v| narrow(v, 1023)).collect(); + let per_line = (w as usize).checked_mul(designators.len()).ok_or_else(|| enc("image too large".into()))?; + if codes.len() != per_line.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? { + return Err(enc("the image has the wrong number of samples".into())); + } + let data_len = line_bytes(per_line, 10, 1).and_then(|l| l.checked_mul(h as usize)).ok_or_else(|| enc("image too large".into()))?; + let total = + CINEON_HEADER_LEN.checked_add(data_len).and_then(|t| u32::try_from(t).ok()).ok_or_else(|| enc("image too large for a Cineon file (4 GiB)".into()))?; + + let mut wr = Writer { h: vec![0; CINEON_HEADER_LEN] }; + // File information. + wr.u32(0, 0x802A_5FD7); + wr.u32(4, CINEON_HEADER_LEN as u32); + wr.u32(8, CINEON_GENERIC_LEN); + wr.u32(12, CINEON_HEADER_LEN as u32 - CINEON_GENERIC_LEN); + wr.u32(16, 0); + wr.u32(20, total); + wr.string((24, 8), "V4.5"); + // Image information: default orientation, then each channel's descriptor; the unused ones, + // the white point and the primaries are undefined. + wr.u8(192, 0); + wr.u8(193, designators.len() as u8); + wr.undefined(CINEON_CHANNEL0, 420); + for (i, &designator) in designators.iter().enumerate() { + let c = CINEON_CHANNEL0 + 28 * i; + wr.u8(c, 0); + wr.u8(c + 1, designator); + wr.u8(c + 2, 10); + wr.u8(c + 3, 0); + wr.u32(c + 4, w); + wr.u32(c + 8, h); + // Code values 0–1023 stand for printing densities 0–2.048, the Cineon convention. All + // four are 32-bit floats here (DPX stores the data values as integers). + wr.u32(c + 12, 0f32.to_bits()); + wr.u32(c + 16, 0f32.to_bits()); + wr.u32(c + 20, 1023f32.to_bits()); + wr.u32(c + 24, 2.048f32.to_bits()); + } + // Pixel-interleaved, unsigned, positive, no line or channel padding. + wr.u8(681, 5); + // Image origination: pixel pitches and gamma unknown. + wr.undefined(972, 984); + // Film header: manufacturer, type, perforation offset, prefix, count, frame position and rate + // unknown; its strings stay empty. + wr.undefined(1024, 1036); + wr.undefined(1068, 1076); + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| enc("out of memory".into()))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, 10); + } + Ok(out) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index a55810b4b8..2b32a2b7c4 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -15,7 +15,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; -use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name}; +use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels}; use crate::options::{EncodeOptions, ExrCompression, Limits}; const F: Format = Format::OpenExr; @@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>) -> Result { let (w, h) = (layer.size.0, layer.size.1); let channels = &layer.channel_data.list; - let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n); - let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - match find("A") { - Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), - None => (vec![r, g, b], ChannelLayout::Rgb), - } - } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { - match find("A") { - Some(a) => (vec![y, a], ChannelLayout::GrayA), - None => (vec![y], ChannelLayout::Gray), - } - } else { - return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); - }; + let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect(); + let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?; + let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::>()?; let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 }; let npx = w * h; @@ -175,7 +164,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // composite the samples into the flat image (a warning says what was lost). if meta.requirements.has_deep_data { let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?; - return super::deep_exr::flatten(&deep); + return super::deep_exr::flatten(&deep, limits); } let pick = pick_part(&meta); diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 5f13f98621..370c7c7e56 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -156,9 +156,13 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result> = std::collections::HashMap::new(); for key in cryptomatte_keys(&header.own_attributes.other) { let Some(name) = attribute(&header.own_attributes.other, &key, "name") else { continue }; - let channels = layer_streams(header, name.as_str()); + let channels = streams_for.entry(name.clone()).or_insert_with(|| layer_streams(header, name.as_str())).clone(); if channels.is_empty() { continue; } @@ -184,11 +188,15 @@ pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result/` key of a header, in file order, without duplicates. fn cryptomatte_keys(other: &std::collections::HashMap) -> Vec { let mut keys = Vec::new(); + // Track seen keys in a set: the attribute names are file-controlled, so membership with + // keys.iter().any() is O(n^2) in the number of distinct `cryptomatte//` + // groups. The Vec still records file order; the set only answers "seen before". + let mut seen = std::collections::HashSet::new(); for name in other.keys() { let full = name.to_string(); let Some(rest) = full.strip_prefix("cryptomatte/") else { continue }; let Some((key, _field)) = rest.split_once('/') else { continue }; - if !key.is_empty() && !keys.iter().any(|k| k == key) { + if !key.is_empty() && seen.insert(key.to_string()) { keys.push(key.to_string()); } } @@ -217,14 +225,18 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< let too_large = || CodecError::malformed(F, "the layer is too large"); let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?); // Each pixel also gets a pair list, so count its header with the channel samples (four - // f32 channels per set: two ranks of ID and coverage). - let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64); + // f32 channels per set: two ranks of ID and coverage). Include the heap capacity the pair + // list can reach: up to one (id, coverage) pair per rank, 8 bytes each. Without this the + // returned per-pixel vectors could exceed the caller's max_alloc budget. + let ranks = layer.channels.len().saturating_mul(2); + let pair_capacity = (ranks as u64).saturating_mul(std::mem::size_of::<(f32, f32)>() as u64); + let per_pixel = + (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64).saturating_add(pair_capacity); limits.check_bytes(w32, h32, per_pixel)?; let ex_layer = super::exr::read_layer(bytes, layer.part)?; // Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`. - let ranks = layer.channels.len().saturating_mul(2); let mut ids: Vec> = vec![Vec::new(); ranks]; let mut coverages: Vec> = vec![Vec::new(); ranks]; for c in &ex_layer.channel_data.list { diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 7e3f92ae67..214d968944 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -22,6 +22,8 @@ //! (strings longer than one card on `CONTINUE` cards). //! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused. +use std::collections::HashMap; + use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; @@ -106,13 +108,19 @@ fn parse_card(raw: &[u8]) -> Card { struct Header { cards: Vec, + /// First-occurrence index of each non-HIERARCH keyword, so `get` is O(1). The card + /// count and the number of looked-up keywords (NAXIS1..NAXISn) are both file-controlled, + /// so a linear scan per lookup lets a crafted header multiply commentary cards by axis + /// count into quadratic work. + index: HashMap, /// Offset of the data unit. data_start: usize, } impl Header { fn get(&self, key: &str) -> Option<&Value> { - self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref()) + let c = &self.cards[*self.index.get(key)?]; + c.value.as_ref() } fn int(&self, key: &str) -> Option { @@ -167,7 +175,15 @@ fn parse_header(b: &[u8], start: usize) -> Result { cards.push(card); } let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?; - Ok(Header { cards, data_start }) + // Index each non-HIERARCH keyword to its first occurrence, matching the previous + // `find(|c| !c.hierarch && c.key == key)` lookup while making `get` O(1). + let mut index = HashMap::with_capacity(cards.len()); + for (i, c) in cards.iter().enumerate() { + if !c.hierarch { + index.entry(c.key.clone()).or_insert(i); + } + } + Ok(Header { cards, index, data_start }) } /// The axis lengths (`NAXIS1`, `NAXIS2`, …). diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs index a5bed96254..6a99b54f7b 100644 --- a/crates/codecs/src/codecs/gif_frames.rs +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -14,16 +14,16 @@ use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, Image}; use crate::options::DecodeOptions; -/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise -/// ask for millions of them while staying inside the byte budget. +/// Most frames one GIF (or animated WebP) may yield. Frames are held in memory, and a tiny +/// canvas could otherwise ask for millions of them while staying inside the byte budget. pub const MAX_GIF_FRAMES: usize = 100_000; /// One composited frame of an animation and how long it stays on screen. #[derive(Debug, Clone, PartialEq)] pub struct AnimationFrame { pub image: Image, - /// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a - /// multiple of 10 (0 when the file gives none). + /// On-screen time in milliseconds. GIF counts in hundredths of a second, so there this is a + /// multiple of 10 (0 when the file gives none); WebP counts in milliseconds. pub delay_ms: u32, } diff --git a/crates/codecs/src/codecs/gif_palette.rs b/crates/codecs/src/codecs/gif_palette.rs new file mode 100644 index 0000000000..eb0a0e2180 --- /dev/null +++ b/crates/codecs/src/codecs/gif_palette.rs @@ -0,0 +1,126 @@ +//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a +//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control +//! Extension). +//! +//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that +//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global +//! one, and the transparent entry that frame's Graphic Control Extension names. + +/// Colours of the table the first frame uses, and its transparent entry. `None` for anything +/// that isn't a well-formed GIF up to its first image, or a first frame without a table. +pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { + if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) { + return None; + } + // Logical Screen Descriptor: width, height, packed fields, background, aspect. + let packed = *b.get(10)?; + let mut at = 13; + let table = |at: usize, packed: u8| -> Option> { + let n = 2usize << (packed & 0x07); + let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?; + Some(bytes.as_chunks::<3>().0.to_vec()) + }; + let global = if packed & 0x80 != 0 { + let t = table(at, packed)?; + at += t.len() * 3; + Some(t) + } else { + None + }; + let mut transparent = None; + loop { + match *b.get(at)? { + // Extension: label, then data sub-blocks up to a zero-length one. + 0x21 => { + let label = *b.get(at + 1)?; + at += 2; + // Graphic Control Extension: block size 4, packed (bit 0: transparent colour + // given), delay (2 bytes), transparent colour index. + if label == 0xF9 && *b.get(at)? == 4 { + let flags = *b.get(at + 1)?; + transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?); + } + loop { + let size = usize::from(*b.get(at)?); + at = at.checked_add(1 + size)?; + if size == 0 { + break; + } + } + } + // Image Descriptor: position and size (8 bytes), packed fields, then the Local Color + // Table if bit 7 is set. This is the first frame: its table is the one used. + 0x2C => { + let packed = *b.get(at + 9)?; + let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? }; + let transparent = transparent.filter(|i| usize::from(*i) < colors.len()); + return Some((colors, transparent)); + } + // Trailer, or anything else: no image. + _ => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::palette; + + /// A GIF up to its first image: `global` table entries, optional GCE transparency, optional + /// local table. + fn gif(global: &[[u8; 3]], transparent: Option, local: Option<&[[u8; 3]]>) -> Vec { + let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8; + let mut b = b"GIF89a".to_vec(); + b.extend([4, 0, 4, 0]); + b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) }); + b.extend([0, 0]); + let pad = |b: &mut Vec, t: &[[u8; 3]]| { + let n = 2usize << size_bits(t.len()); + for k in 0..n { + b.extend(t.get(k).copied().unwrap_or([0, 0, 0])); + } + }; + if !global.is_empty() { + pad(&mut b, global); + } + // A comment extension first, to skip. + b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]); + if let Some(i) = transparent { + b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]); + } + b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]); + match local { + Some(t) => { + b.push(0x80 | size_bits(t.len())); + pad(&mut b, t); + } + None => b.push(0), + } + b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]); + b + } + + #[test] + fn the_first_frames_table_and_transparent_entry() { + let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]]; + assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None))); + assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2)))); + // A local table wins over the global one. + let l = [[1, 2, 3], [4, 5, 6]]; + assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1)))); + // A transparent index past the table is no transparent entry. + assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None))); + } + + #[test] + fn no_table_or_not_a_gif_is_none() { + assert_eq!(palette(&gif(&[], None, None)), None, "no table at all"); + assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None); + assert_eq!(palette(b""), None); + let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None); + for cut in 0..whole.len() - 6 { + // Cut short anywhere before the image descriptor's fields: never a panic. + let _ = palette(&whole[..cut]); + } + } +} diff --git a/crates/codecs/src/codecs/heif.rs b/crates/codecs/src/codecs/heif.rs index 7f26133b1d..efd9d2073a 100644 --- a/crates/codecs/src/codecs/heif.rs +++ b/crates/codecs/src/codecs/heif.rs @@ -36,6 +36,7 @@ fn err(e: photocraft_heif::Error) -> CodecError { #[cfg(feature = "heif")] pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result { + use crate::cicp::Cicp; use crate::image::{ChannelLayout, Metadata, SampleType}; // The container alone: the declared size is checked before any pixel is decoded. @@ -44,6 +45,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 }; let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) }; limits.check(w, h, layout, sample)?; + // The delegated decoder allocates the full coded image before applying any clean-aperture + // crop or orientation transform, so the coded dimensions must pass the limit policy even + // when the displayed (transformed) size is small. Checking only the transformed size let a + // tiny visible image hide a large coded allocation. + if !keep_orientation && (info.coded_width, info.coded_height) != (w, h) { + limits.check(info.coded_width, info.coded_height, layout, sample)?; + } let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation }; let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?; // The decoder re-reads the container; trust its output's shape, not the probe's. @@ -51,12 +59,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 }; let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?; img.icc = decoded.icc; + img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range }); img.meta = Metadata { exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }), xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }), ..Default::default() }; - Ok(img) + // A 10-bit HDR HEIC (BT.2100 HLG or PQ) opens as linear light in 32-bit float (#6752): no + // ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights. + match img.cicp.filter(|c| c.is_hdr()) { + Some(c) => { + limits.check(decoded.width, decoded.height, layout, SampleType::F32)?; + crate::cicp::linearize(img, c) + } + None => Ok(img), + } } #[cfg(test)] diff --git a/crates/codecs/src/codecs/iff.rs b/crates/codecs/src/codecs/iff.rs new file mode 100644 index 0000000000..cae046f3b7 --- /dev/null +++ b/crates/codecs/src/codecs/iff.rs @@ -0,0 +1,354 @@ +//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky +//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte +//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data). +//! Read-only: modern saves should use PNG. +//! +//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2, +//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear +//! `Unsupported`/`Malformed` error, never a panic. + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Iff; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +fn be32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +fn be16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(u16::from_be_bytes(s)) +} + +/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` chunky) at offset 8. +pub(crate) fn looks_like_iff(b: &[u8]) -> bool { + b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM ")) +} + +/// The bitmap header fields decoding needs. +struct Header { + width: u32, + height: u32, + planes: u8, + masking: u8, + compression: u8, + transparent: u16, + /// `PBM ` stores one palette index per byte, padded to even row lengths. + chunky: bool, +} + +/// One four-byte chunk's id and byte range. +struct Chunk<'a> { + id: [u8; 4], + data: &'a [u8], +} + +/// Walk the `FORM`'s chunks from offset 12 to `end` (the form length), stopping at the first +/// malformed length so a hostile file can't run past the buffer. +fn chunks(b: &[u8]) -> impl Iterator> { + let end = be32(b, 4).map_or(b.len(), |n| (n as usize).saturating_add(8).min(b.len())); + let mut pos = 12usize; + std::iter::from_fn(move || { + if pos + 8 > end { + return None; + } + let id = b.get(pos..pos + 4)?.try_into().ok()?; + let len = be32(b, pos + 4)? as usize; + let start = pos + 8; + let data = b.get(start..start.checked_add(len)?)?; + // Chunks are padded to an even length. + pos = start.checked_add(len)?.checked_add(len & 1)?; + Some(Chunk { id, data }) + }) +} + +fn parse_header(b: &[u8], chunky: bool) -> Result { + let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?; + let d = bmhd.data; + if d.len() < 20 { + return Err(bad("BMHD chunk is too short")); + } + let width = u32::from(be16(d, 0).ok_or_else(|| bad("BMHD width"))?); + let height = u32::from(be16(d, 2).ok_or_else(|| bad("BMHD height"))?); + let planes = *d.get(8).ok_or_else(|| bad("BMHD planes"))?; + let masking = *d.get(9).ok_or_else(|| bad("BMHD masking"))?; + let compression = *d.get(10).ok_or_else(|| bad("BMHD compression"))?; + if width == 0 || height == 0 { + return Err(bad("zero-sized bitmap")); + } + if !(1..=8).contains(&planes) { + return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)"))); + } + if masking > 2 { + return Err(unsupported(format!("masking {masking}"))); + } + if compression > 1 { + return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)"))); + } + if chunky && (planes != 8 || masking == 1) { + return Err(unsupported("PBM requires 8-bit indices without a mask plane")); + } + let transparent = be16(d, 12).ok_or_else(|| bad("BMHD transparent colour"))?; + Ok(Header { width, height, planes, masking, compression, transparent, chunky }) +} + +/// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80` +/// control byte is a no-op, as the layout specifies. +fn unpack_byte_run1(src: &[u8], out: &mut [u8]) -> Result { + let (mut i, mut o) = (0usize, 0usize); + while o < out.len() { + let n = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 data"))? as i8; + i += 1; + if n >= 0 { + let count = n as usize + 1; + let lit = src.get(i..i.checked_add(count).ok_or_else(|| bad("ByteRun1 overflow"))?).ok_or_else(|| bad("truncated ByteRun1 run"))?; + if count > out.len() - o { + return Err(bad("ByteRun1 run crosses the plane row")); + } + out[o..o + count].copy_from_slice(lit); + i += count; + o += count; + } else if n != -128 { + let count = (1 - n as i32) as usize; + let value = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 repeat"))?; + i += 1; + if count > out.len() - o { + return Err(bad("ByteRun1 repeat crosses the plane row")); + } + out[o..o + count].fill(value); + o += count; + } + } + Ok(i) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !looks_like_iff(b) { + return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)")); + } + let h = parse_header(b, b.get(8..12) == Some(b"PBM "))?; + if let Some(camg) = chunks(b).find(|c| &c.id == b"CAMG") { + let mode = be32(camg.data, 0).ok_or_else(|| bad("short CAMG chunk"))?; + if mode & (0x80 | 0x800) != 0 { + return Err(unsupported("EHB and HAM viewport modes")); + } + } + let layout = if h.masking == 0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }; + limits.check(h.width, h.height, layout, SampleType::U8)?; + + // Palette: three bytes per colour; missing entries read as black. + let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data).filter(|p| !p.is_empty()); + let colors = 1usize << h.planes; + let palette = |idx: usize| -> [u8; 3] { + if let Some(p) = cmap { + p.get(idx * 3..idx * 3 + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([0; 3]) + } else { + let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8; + [v, v, v] + } + }; + + let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?; + let (w, hh) = (h.width as usize, h.height as usize); + // Each plane row is stored as a whole number of 16-bit words. + let plane_row = if h.chunky { w.div_ceil(2) * 2 } else { w.div_ceil(16) * 2 }; + // A masking type 1 bitmap carries one extra (mask) plane per row. + let rows_planes: usize = if h.chunky { 1 } else { usize::from(h.planes) + usize::from(h.masking == 1) }; + + // Decode every stored plane row up front, then recombine. + let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + if total_bytes as u64 > limits.max_alloc { + return Err(CodecError::LimitExceeded("bitmap storage exceeds max_alloc".into())); + } + Limits::check_plausible(total_bytes as u64, body.len(), if h.compression == 0 { 1 } else { 128 })?; + let mut stored = crate::options::alloc_zeroed::(total_bytes)?; + let mut cursor = 0usize; + for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) { + match h.compression { + 0 => { + let src = body.get(cursor..cursor.checked_add(plane_row).ok_or_else(|| bad("BODY overflow"))?).ok_or_else(|| bad("truncated BODY data"))?; + let n = row.len().min(src.len()); + row[..n].copy_from_slice(&src[..n]); + cursor += plane_row; + } + _ => { + let tail = body.get(cursor..).ok_or_else(|| bad("truncated BODY data"))?; + cursor += unpack_byte_run1(tail, row)?; + } + } + } + + let mut out = Vec::new(); + out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(layout.channels())).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + let planes = usize::from(h.planes); + for y in 0..hh { + for x in 0..w { + let index = if h.chunky { + usize::from(*stored.get(y * plane_row + x).ok_or_else(|| bad("PBM pixel outside BODY"))?) + } else { + (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + }; + let c = palette(index); + out.extend_from_slice(&c); + if h.masking != 0 { + let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) }; + out.push(if opaque { 255 } else { 0 }); + } + } + } + Image::from_u8(h.width, h.height, layout, out) +} + +/// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first). +fn bit_at(stored: &[u8], base: usize, x: usize) -> bool { + stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `FORM ILBM` with a `BMHD`, `CMAP` and `BODY`. `planes` plane rows per row, + /// `compression` 0 (stored) or 1 (ByteRun1), MSB-first. + fn ilbm(w: u16, h: u16, planes: u8, palette: &[[u8; 3]], compression: u8, body: &[u8]) -> Vec { + let mut b = b"FORM".to_vec(); + b.extend_from_slice(&0u32.to_be_bytes()); // patched below + b.extend_from_slice(b"ILBM"); + // BMHD (20 bytes) + b.extend_from_slice(b"BMHD"); + b.extend_from_slice(&20u32.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&[0, 0, 0, 0]); // x/y origin + b.push(planes); + b.push(0); // masking + b.push(compression); + b.push(0); // pad + b.extend_from_slice(&[0, 0]); // transparent colour + b.extend_from_slice(&[1, 1]); // aspect + b.extend_from_slice(&[0, 0, 0, 0]); // page size + // CMAP + b.extend_from_slice(b"CMAP"); + b.extend_from_slice(&((palette.len() * 3) as u32).to_be_bytes()); + for c in palette { + b.extend_from_slice(c); + } + if palette.len() % 2 == 1 { + b.push(0); + } + // BODY + b.extend_from_slice(b"BODY"); + b.extend_from_slice(&(body.len() as u32).to_be_bytes()); + b.extend_from_slice(body); + if body.len() % 2 == 1 { + b.push(0); + } + let size = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&size.to_be_bytes()); + b + } + + #[test] + fn reads_a_stored_two_plane_bitmap() { + // 2×1, 2 planes: pixel 0 index 0 (plane bits 0,0), pixel 1 index 3 (bits 1,1). + // Plane 0 = 0b0100_0000, plane 1 = 0b0100_0000, each padded to 2 bytes. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120]]; + let b = ilbm(2, 1, 2, &pal, 0, &body); + assert!(looks_like_iff(&b)); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!(img.data(), &[10, 20, 30, 100, 110, 120]); + } + + #[test] + fn reads_byte_run1_compressed_rows() { + // Same image, ByteRun1: literal run of two bytes (0x01) then the two plane bytes each row. + let body = [0x01, 0b0100_0000, 0, 0x01, 0b0100_0000, 0]; + let pal = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [250, 251, 252]]; + let b = ilbm(2, 1, 2, &pal, 1, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 250, 251, 252]); + } + + #[test] + fn no_palette_falls_back_to_a_grey_ramp() { + // 2×1, 1 plane, no CMAP: indices 0 and 1 → black and white. + let body = [0b0100_0000, 0]; + let b = ilbm(2, 1, 1, &[], 0, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]); + } + + #[test] + fn pbm_reads_chunky_indices_with_odd_width_padding() { + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90]]; + for (compression, body) in [(0, vec![2, 0, 1, 255, 1, 2, 0, 255]), (1, vec![3, 2, 0, 1, 255, 3, 1, 2, 0, 255])] { + let mut b = ilbm(3, 2, 8, &pal, compression, &body); + b[8..12].copy_from_slice(b"PBM "); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[70, 80, 90, 10, 20, 30, 40, 50, 60, 40, 50, 60, 70, 80, 90, 10, 20, 30]); + } + } + + #[test] + fn transparency_uses_the_mask_plane_or_transparent_index() { + for masking in [1, 2] { + let mut b = ilbm(2, 1, 1, &[[10, 20, 30], [40, 50, 60]], 0, if masking == 1 { &[0x40, 0, 0x40, 0] } else { &[0x40, 0] }); + b[29] = masking; + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.data(), &[10, 20, 30, 0, 40, 50, 60, 255]); + } + let mut b = ilbm(2, 1, 8, &[[10, 20, 30], [40, 50, 60]], 0, &[0, 1]); + b[8..12].copy_from_slice(b"PBM "); + b[29] = 2; + b[32..34].copy_from_slice(&1u16.to_be_bytes()); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[10, 20, 30, 255, 40, 50, 60, 0]); + } + + #[test] + fn rejects_ham_and_ehb_instead_of_returning_wrong_colours() { + for mode in [0x80u32, 0x800] { + let mut b = ilbm(1, 1, 1, &[], 0, &[0, 0]); + b.extend_from_slice(b"CAMG"); + b.extend_from_slice(&4u32.to_be_bytes()); + b.extend_from_slice(&mode.to_be_bytes()); + let len = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&len.to_be_bytes()); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } + } + + #[test] + fn missing_palette_entries_are_black() { + let b = ilbm(1, 1, 2, &[[10, 20, 30]], 0, &[0x80, 0, 0x80, 0]); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]); + } + + #[test] + fn hostile_input_errors_not_panics() { + // Truncations of a valid file never panic. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let full = ilbm(2, 1, 2, &[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], 0, &body); + for cut in [0, 4, 8, 12, 40, full.len() - 1] { + assert!(decode(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + // Too many planes is unsupported. + let bad = ilbm(1, 1, 9, &[], 0, &[0, 0]); + assert!(matches!(decode(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 9c37628872..cf1c9fca43 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -7,8 +7,9 @@ //! these wins: the APP13 Photoshop ResolutionInfo, the XMP `tiff:` resolution, the EXIF IFD0 //! resolution, and last the JFIF density (a unit-0 JFIF density is only an aspect ratio and //! never counts). Cameras record their resolution in EXIF only, and many tools write a default -//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export the JFIF -//! density, the EXIF and the XMP resolution are all written as the image's resolution. +//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export RGB and gray +//! images carry density in JFIF; CMYK omits JFIF APP0, which is invalid for four components, and +//! carries an available density in EXIF instead. use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; @@ -18,7 +19,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits}; +use crate::options::{EncodeOptions, JpegFormat, Limits}; use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; const F: Format = Format::Jpeg; @@ -33,6 +34,8 @@ pub(crate) struct JpegMeta { pub exif: Option>, pub xmp: Option, pub dpi: Option<(f32, f32)>, + /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header. + pub photoshop: Option>, pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, @@ -108,6 +111,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { .or_else(|| m.xmp.as_deref().and_then(xmp_resolution)) .or_else(|| m.exif.as_deref().and_then(exif_resolution)) .or(jfif_dpi); + m.photoshop = Some(photoshop).filter(|p| !p.is_empty()); if !icc_parts.is_empty() { icc_parts.sort_by_key(|p| p.0); let total = icc_parts[0].1 as usize; @@ -226,7 +230,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; img.icc = meta.icc; - img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() }; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; if end == DataEnd::Truncated { img.warnings.push(DecodeWarning::Truncated { format: F }); } @@ -252,15 +256,21 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result= 1.0 && y >= 1.0 { @@ -268,7 +278,16 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result exif_segment_with_resolution(exif, img.meta.dpi), + None if usable_dpi(img.meta.dpi) => exif_segment_with_resolution(&[], img.meta.dpi), + None => None, + } + } else { + img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi)) + }; + if let Some(seg) = exif { enc.add_app_segment(1, &seg).map_err(e)?; } if let Some(seg) = img.meta.xmp.as_deref().and_then(|x| xmp_segment(x, img.meta.dpi)) { @@ -281,9 +300,58 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result) { + if !bytes.starts_with(&[0xff, 0xd8]) { + return; + } + let mut i = 2usize; + loop { + if bytes.get(i) != Some(&0xff) { + return; + } + let Some(&marker) = bytes.get(i.saturating_add(1)) else { return }; + if matches!(marker, 0xda | 0xd9) { + return; + } + if marker == 0xff { + i = i.saturating_add(1); + continue; + } + if marker == 0x01 || (0xd0..=0xd8).contains(&marker) { + i = i.saturating_add(2); + continue; + } + let Some(length_bytes) = i.checked_add(2).and_then(|at| bytes.get(at..at.checked_add(2)?)) else { return }; + let Ok(length_bytes) = <[u8; 2]>::try_from(length_bytes) else { return }; + let length = usize::from(u16::from_be_bytes(length_bytes)); + if length < 2 { + return; + } + let Some(end) = i.checked_add(2).and_then(|at| at.checked_add(length)) else { return }; + if end > bytes.len() { + return; + } + if marker == 0xe0 && bytes.get(i.saturating_add(4)..i.saturating_add(9)) == Some(b"JFIF\0") { + bytes.drain(i..end); + } else { + i = end; + } + } +} + +/// Return whether both resolution values are finite and within the supported metadata range. +fn usable_dpi(dpi: Option<(f32, f32)>) -> bool { + dpi.is_some_and(|(x, y)| x.is_finite() && y.is_finite() && (1.0..=1_000_000.0).contains(&x) && (1.0..=1_000_000.0).contains(&y)) +} + /// The most data one APP segment holds (its 16-bit length counts its own two bytes). const MAX_SEGMENT: usize = 65533; @@ -294,12 +362,18 @@ const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"]; /// The EXIF APP1 segment, or `None` when it doesn't fit in one segment. pub(crate) fn exif_segment(exif: &[u8], dpi: Option<(f32, f32)>) -> Option> { - // The pixels are written as they are shown: never let a viewer rotate them again; and the - // resolution is the one written to JFIF, not the source's (#1691). + // The pixels are written as they are shown: never let a viewer rotate them again; and store + // the output resolution, not the source's (#1691). let seg = [EXIF_HEADER, export_exif(exif, dpi).as_ref()].concat(); (seg.len() <= MAX_SEGMENT).then_some(seg) } +/// Build an EXIF APP1 segment, adding missing resolution tags when the source metadata is valid. +fn exif_segment_with_resolution(exif: &[u8], dpi: Option<(f32, f32)>) -> Option> { + let seg = [EXIF_HEADER, crate::resolution::export_exif_adding_missing_resolution(exif, dpi).as_ref()].concat(); + (seg.len() <= MAX_SEGMENT).then_some(seg) +} + /// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document /// properties are left out; `None` when it still doesn't fit. pub(crate) fn xmp_segment(xmp: &str, dpi: Option<(f32, f32)>) -> Option> { @@ -344,3 +418,74 @@ fn remove_element(xmp: &str, name: &str) -> String { out.push_str(rest); out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Format, Metadata}; + + /// Build a little-endian TIFF with an empty IFD0 for metadata tests. + fn empty_tiff() -> Vec { + // Little-endian TIFF with an empty IFD0 and no next IFD. + b"II*\0\x08\0\0\0\0\0\0\0\0\0\0\0".to_vec() + } + + /// Return whether the JPEG begins with a JFIF APP0 segment. + fn has_jfif_app0(bytes: &[u8]) -> bool { + bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") + } + + /// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker. + #[test] + fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() { + let img = Image::from_raw(2, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 32, 64, 96, 255, 224, 192, 160]).unwrap(); + let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + + let meta = scan_metadata(&bytes); + assert!(!has_jfif_app0(&bytes)); + assert_eq!(meta.frame, Some((2, 1, 4))); + assert_eq!(meta.adobe_transform, Some(0)); + assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().layout(), ChannelLayout::Cmyk); + } + + /// CMYK JPEG output stores image resolution in EXIF when the source has no EXIF. + #[test] + fn cmyk_jpeg_preserves_dpi_without_source_exif() { + let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap(); + img.meta.dpi = Some((300.0, 150.0)); + let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + + assert!(!has_jfif_app0(&bytes)); + assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((300.0, 150.0))); + } + + /// CMYK JPEG output adds missing EXIF resolution tags to existing metadata. + #[test] + fn cmyk_jpeg_preserves_dpi_with_source_exif_without_resolution_tags() { + let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap(); + img.meta = Metadata { exif: Some(empty_tiff()), dpi: Some((240.0, 120.0)), ..Default::default() }; + let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + + assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((240.0, 120.0))); + } + + /// Adding missing resolution tags makes them readable from the rewritten IFD0. + #[test] + fn exif_with_resolution_adds_missing_ifd0_entries() { + let exif = empty_tiff(); + let rewritten = crate::resolution::export_exif_adding_missing_resolution(&exif, Some((240.0, 120.0))); + + assert_eq!(crate::resolution::exif_resolution(&rewritten), Some((240.0, 120.0))); + } + + /// RGB JPEG output continues to store image resolution in JFIF density fields. + #[test] + fn rgb_jpeg_keeps_jfif_density() { + let mut img = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U8, vec![12, 34, 56]).unwrap(); + img.meta.dpi = Some((96.0, 96.0)); + let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + + assert!(has_jfif_app0(&bytes)); + assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0))); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 32e68b336a..efdbbf8229 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,11 +1,15 @@ pub(crate) mod dds; +pub(crate) mod dds_bc; pub(crate) mod deep_exr; +pub(crate) mod dicom; pub(crate) mod dpx; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; pub(crate) mod gif_frames; +pub(crate) mod gif_palette; pub(crate) mod heif; +pub(crate) mod iff; pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index 43a3e97302..f89a781e9d 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -34,7 +34,7 @@ pub(crate) fn looks_like_pcx(b: &[u8]) -> bool { stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4) } -/// Read one compressed plane's row. RLE packets never cross a scanline. +/// Read one compressed scanline, including all planes. RLE packets never cross a scanline. fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> { let mut n = 0; while n < out.len() { @@ -77,9 +77,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let mut p = HEADER; let width = w as usize; for y in 0..h as usize { - for plane in row.chunks_mut(stride) { - read_row(&b[..image_end], &mut p, plane)?; - } + read_row(&b[..image_end], &mut p, &mut row)?; for x in 0..width { let rgb = if bits == 8 && planes == 3 { [row[x], row[stride + x], row[2 * stride + x]] diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..085246b3f1 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; +use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result, CodecError> { Ok(Some(PngPalette { colors, transparent })) } +/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read +/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a +/// defined mode byte. The first valid chunk wins, as a later one would contradict it. +pub fn stereo(bytes: &[u8]) -> Option { + let mut rest = bytes.get(8..)?; + while let Some((length, tail)) = rest.split_first_chunk::<4>() { + let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break }; + let length = u32::from_be_bytes(*length); + if length > i32::MAX as u32 { + break; + } + let Ok(length) = usize::try_from(length) else { break }; + let Some(data) = tail.get(..length) else { break }; + let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break }; + rest = next; + if kind == b"IEND" { + break; + } + if kind != b"sTER" || data.len() != 1 { + continue; + } + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + if crc.sum() != u32::from_be_bytes(*checksum) { + continue; + } + return PngStereo::from_mode(data[0]); + } + None +} + fn map_png_err(e: png::DecodingError) -> CodecError { match e { png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()), @@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -515,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -574,6 +607,7 @@ pub fn encode_indexed( palette: &[[u8; 3]], transparent: Option, dpi: Option<(f32, f32)>, + stereo: Option, ) -> Result, CodecError> { if width == 0 || height == 0 || indices.len() != width as usize * height as usize { return Err(CodecError::InvalidImage("index data does not match the size".into())); @@ -603,12 +637,113 @@ pub fn encode_indexed( { let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = stereo { + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?; writer.finish().map_err(|e| CodecError::encode(F, e))?; } Ok(out) } +#[cfg(test)] +mod stereo_tests { + use super::*; + + /// One CRC-valid chunk as it appears in a file. + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + fn png_with(stereo: &[u8]) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0])); + if !stereo.is_empty() { + out.extend_from_slice(&chunk(b"sTER", stereo)); + } + out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0])); + out.extend_from_slice(&chunk(b"IEND", &[])); + out + } + + #[test] + fn reads_both_defined_views() { + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::CrossFuse)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::DivergingFuse)); + assert_eq!(stereo(&png_with(&[])), None); + // Reserved and unknown mode bytes are not a pairing we can keep. + assert_eq!(stereo(&png_with(&[2])), None); + assert_eq!(stereo(&png_with(&[255])), None); + // A two-byte payload is not sTER data. + assert_eq!(stereo(&png_with(&[0, 0])), None); + assert_eq!(stereo(b"not a png"), None); + } + + #[test] + fn ster_mode_bytes_describe_eye_order_not_vertical_packing() { + // PNG Extensions 1.4.0 §3.6: both views occupy horizontal halves. + // Each view is eight columns, so the second starts on an eight-column boundary. + for (mode, left_pixel, right_pixel) in [(0, [0, 0, 255], [255, 0, 0]), (1, [255, 0, 0], [0, 0, 255])] { + let pixels: Vec = [left_pixel; 8].into_iter().chain([right_pixel; 8]).flatten().collect(); + let img = Image::from_raw(16, 1, ChannelLayout::Rgb, SampleType::U8, pixels.clone()).unwrap(); + let pairing = if mode == 0 { PngStereo::CrossFuse } else { PngStereo::DivergingFuse }; + let opts = EncodeOptions { png_stereo: Some(pairing), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + let chunk = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + assert_eq!(bytes[chunk + 4], mode, "the specification's literal mode byte"); + let decoded = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(decoded.dimensions(), (16, 1)); + assert_eq!(decoded.data(), pixels); + } + } + + #[test] + fn a_broken_crc_is_not_a_stereo_mark() { + let mut bytes = png_with(&[0]); + let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + // The CRC trails the type and the one-byte payload of the chunk. + bytes[kind + 8] ^= 0xff; + assert_eq!(stereo(&bytes), None); + } + + #[test] + fn encode_writes_the_chunk_before_idat_and_it_reads_back() { + let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { + let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(view)); + let back = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(back.data(), img.data()); + let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap(); + assert!(ter < idat, "sTER before IDAT"); + } + let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(stereo(&plain), None); + } + + #[test] + fn interlaced_and_indexed_encoders_keep_the_mark() { + let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); + let opts = EncodeOptions { png_stereo: Some(PngStereo::DivergingFuse), png_interlaced: true, ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::DivergingFuse)); + let pal = [[255, 0, 0], [0, 0, 255]]; + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::CrossFuse)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::CrossFuse)); + assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); + } +} + #[cfg(test)] mod indexed_tests { use super::*; @@ -617,14 +752,14 @@ mod indexed_tests { fn png8_roundtrips_through_the_decoder() { let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]]; let idx = [0u8, 1, 2, 1, 0, 2]; - let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap(); + let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap(); let img = decode(&bytes, &Limits::default()).unwrap(); assert_eq!(img.dimensions(), (3, 2)); let px = img.convert(ChannelLayout::Rgba, SampleType::U8); assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]); assert_eq!(px.data()[11], 0, "transparent entry"); - assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err()); - assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err()); + assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err()); + assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err()); } fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec { @@ -643,7 +778,7 @@ mod indexed_tests { #[test] fn palette_reads_the_table_and_its_one_transparent_entry() { - let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap(); + let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap(); let colors = vec![[1, 2, 3], [4, 5, 6]]; assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) })); assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None })); @@ -659,6 +794,109 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); diff --git a/crates/codecs/src/codecs/tga.rs b/crates/codecs/src/codecs/tga.rs index 7b52533d7b..2f382efcf3 100644 --- a/crates/codecs/src/codecs/tga.rs +++ b/crates/codecs/src/codecs/tga.rs @@ -88,3 +88,88 @@ fn rle_row(row: &[u8], bpp: usize, out: &mut Vec) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for (pixels, headers) in [(1, vec![0]), (2, vec![0x81]), (127, vec![0xfe]), (128, vec![0xff]), (129, vec![0xff, 0])] { + let pixel: Vec = (1..=bpp as u8).collect(); + let mut encoded = Vec::new(); + rle_row(&pixel.repeat(pixels), bpp, &mut encoded); + let expected: Vec = headers.into_iter().flat_map(|header| std::iter::once(header).chain(pixel.iter().copied())).collect(); + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } + } + + #[test] + fn raw_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for pixels in [127usize, 128, 129] { + let row: Vec = (0..pixels).flat_map(|x| vec![x as u8; bpp]).collect(); + let mut encoded = Vec::new(); + rle_row(&row, bpp, &mut encoded); + let mut expected = vec![(pixels.min(128) - 1) as u8]; + expected.extend_from_slice(&row[..pixels.min(128) * bpp]); + if pixels > 128 { + expected.push(0); + expected.extend_from_slice(&row[128 * bpp..]); + } + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } + } + + #[test] + fn raw_packets_stop_before_a_run_of_complete_pixels() { + // Equal first channels alone do not make an RGBA run; alpha participates too. + let a = [10, 20, 30, 40]; + let b = [10, 20, 30, 41]; + let c = [50, 60, 70, 80]; + let row = [a, b, c, c, a].concat(); + let mut encoded = Vec::new(); + rle_row(&row, 4, &mut encoded); + assert_eq!(encoded, [vec![1], a.to_vec(), b.to_vec(), vec![0x81], c.to_vec(), vec![0], a.to_vec()].concat()); + } + + #[test] + fn rejects_sample_types_and_layouts_targa_cannot_store() { + for sample in [SampleType::U16, SampleType::F16, SampleType::F32] { + let img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{sample:?}"); + } + for layout in [ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab, ChannelLayout::LabA] { + let img = Image::new(1, 1, layout, SampleType::U8).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{layout:?}"); + } + } + + #[test] + fn dimensions_fit_the_unsigned_16_bit_header() { + for (width, height) in [(65535, 1), (1, 65535)] { + let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap(); + let file = encode(&img).unwrap(); + assert_eq!(&file[12..14], &(width as u16).to_le_bytes()); + assert_eq!(&file[14..16], &(height as u16).to_le_bytes()); + } + for (width, height) in [(65536, 1), (1, 65536)] { + let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{width}x{height}"); + } + } + + #[test] + fn invalid_buffers_are_rejected_before_targa_encoding() { + // Image keeps its buffer private, so malformed buffers must fail at construction. + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let len = 2 * 2 * layout.channels(); + for bytes in [len - 1, len + 1] { + assert!(matches!(Image::from_u8(2, 2, layout, vec![0; bytes]), Err(CodecError::InvalidImage(_))), "{layout:?}: {bytes} bytes"); + } + } + } +} diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs index d07f555f30..2e6885b6e4 100644 --- a/crates/codecs/src/codecs/tiff_exif.rs +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -6,7 +6,9 @@ //! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own //! directories, writing one copies the block's directories into the file. Values are copied as //! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive. -//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. +//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is +//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can +//! overlap (see [`read`]). use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind}; use tiff::tags::{Tag, Type}; @@ -18,6 +20,10 @@ use super::tiff_ifd::{File, Ifd}; pub(crate) const TAG_EXIF_IFD: u16 = 34665; /// IFD0's pointer to the GPS directory. pub(crate) const TAG_GPS_IFD: u16 = 34853; +/// The Exif directory's PixelXDimension and PixelYDimension: the size of the image the +/// directory describes. +const TAG_PIXEL_X_DIMENSION: u16 = 40962; +const TAG_PIXEL_Y_DIMENSION: u16 = 40963; /// The Exif directory's pointer to the Interoperability directory (not carried: it holds only /// the interoperability index, and a copied pointer would point nowhere). const TAG_INTEROP_IFD: u16 = 40965; @@ -27,6 +33,11 @@ const TAG_INTEROP_IFD: u16 = 40965; const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432]; /// At most this many entries are copied from one directory. const MAX_FIELDS: usize = 1024; +/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF +/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for +/// that and for a few such notes. A stream never yields more than its own length either (see +/// [`read`]). +const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024; /// One entry with its value bytes in the byte order of the stream it came from. #[derive(Clone, Debug, PartialEq)] @@ -53,13 +64,22 @@ impl ExifDirs { } } -fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec { - ifd.entries - .iter() - .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) - .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() })) - .take(MAX_FIELDS) - .collect() +/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has +/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may +/// still be copied (shared by all the directories of one stream), and an entry whose value does +/// not fit in what is left is skipped, so later, smaller entries can still be kept. +fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec { + let mut out = Vec::new(); + for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) { + if out.len() >= MAX_FIELDS { + break; + } + let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue }; + let Some(left) = budget.checked_sub(value.len()) else { continue }; + *budget = left; + out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() }); + } + out } fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { @@ -69,14 +89,37 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { /// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its /// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories. +/// +/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values +/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries +/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what +/// is left are left out (the small descriptive directories come first, so a huge maker note +/// cannot crowd out the GPS position). pub(crate) fn read(b: &[u8]) -> Option { - let f = File::parse(b)?; + read_file(File::parse(b)?) +} + +/// The EXIF parts of a camera raw built on TIFF (NEF, ARW, CR2, DNG, Panasonic RW2), as +/// [`read`] gives them, without the Exif directory's PixelXDimension and PixelYDimension: in a +/// raw they give the sensor's or the preview's size, not the developed image's. +pub(crate) fn read_raw(b: &[u8]) -> Option { + let mut dirs = read_file(File::parse_raw(b)?)?; + dirs.exif.retain(|f| !matches!(f.tag, TAG_PIXEL_X_DIMENSION | TAG_PIXEL_Y_DIMENSION)); + Some(dirs) +} + +fn read_file(f: File<'_>) -> Option { + let b = f.b; if f.big { return None; } let ifd0 = f.ifd(f.first_ifd)?; - let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default(); - Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) }) + let mut budget = MAX_COPIED_BYTES.min(b.len()); + let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t)); + let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default(); + let gps = dir(TAG_GPS_IFD); + let exif = dir(TAG_EXIF_IFD); + Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps }) } /// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs. diff --git a/crates/codecs/src/codecs/tiff_ifd.rs b/crates/codecs/src/codecs/tiff_ifd.rs index 199d44ecc7..63b39258cf 100644 --- a/crates/codecs/src/codecs/tiff_ifd.rs +++ b/crates/codecs/src/codecs/tiff_ifd.rs @@ -119,6 +119,20 @@ impl<'a> File<'a> { Some(f) } + /// [`parse`](Self::parse), also taking a Panasonic RW2 raw, whose classic little-endian TIFF + /// header has 0x55 (`IIU\0`) in place of 42. + pub fn parse_raw(b: &'a [u8]) -> Option { + if let Some(f) = Self::parse(b) { + return Some(f); + } + let mut f = File { b, le: true, big: false, first_ifd: 0 }; + if b.get(0..2)? != b"II" || f.u16_at(2)? != 0x55 { + return None; + } + f.first_ifd = u64::from(f.u32_at(4)?); + Some(f) + } + /// Length of the header; no directory can start inside it. pub fn header_len(&self) -> u64 { if self.big { 16 } else { 8 } diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index fd45f6a173..81f4a369ca 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -35,7 +35,51 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError { pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?; - Ok(decoder.get_palette().map(|palette| palette.to_vec())) + let mut palette = decoder.get_palette().map(|palette| palette.to_vec()); + // The decoder always hands back 256 entries (it pads a short table with black to survive + // corrupt files); the file's own count is what the document's colour table should hold. + if let (Some(p), Some(n)) = (palette.as_mut(), bmp_palette_len(bytes)) { + p.truncate(n.max(1)); + } + Ok(palette) +} + +/// How many colours a palette BMP's header declares: `biClrUsed`, or 2^bits when it is 0. +/// `None` for a header this does not recognise, which leaves the table as decoded. +fn bmp_palette_len(bytes: &[u8]) -> Option { + let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let (bit_count, used) = match u32_at(14)? { + // OS/2 BITMAPCOREHEADER: no colour count, the bit depth sets the table size. + 12 => (u16_at(24)?, 0), + n if n >= 40 => (u16_at(28)?, u32_at(46)?), + _ => return None, + }; + if !(1..=8).contains(&bit_count) { + return None; + } + let max = 1usize << bit_count; + Some(if used == 0 { max } else { (used as usize).min(max) }) +} + +/// Writes an 8-bit palette BMP: `indices` row-major, `palette` RGB entries (1..=256). The +/// counterpart of [`bmp_palette`], for Indexed Color documents. +pub(crate) fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result, CodecError> { + let f = Format::Bmp; + if width == 0 || height == 0 || u64::from(width) * u64::from(height) != indices.len() as u64 { + return Err(CodecError::InvalidImage("index data does not match the size".into())); + } + if palette.is_empty() || palette.len() > 256 { + return Err(CodecError::InvalidImage("a palette BMP holds 1 to 256 colours".into())); + } + if indices.iter().any(|&i| usize::from(i) >= palette.len()) { + return Err(CodecError::InvalidImage("palette index out of range".into())); + } + let mut out = Cursor::new(Vec::new()); + image::codecs::bmp::BmpEncoder::new(&mut out) + .encode_with_palette(indices, width, height, image::ExtendedColorType::L8, Some(palette)) + .map_err(|e| CodecError::encode(f, e))?; + Ok(out.into_inner()) } /// The `image` decoder for `f` with our limits, its header read and its size checked. @@ -183,3 +227,45 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - } Ok(cursor.into_inner()) } + +#[cfg(test)] +mod indexed_tests { + use super::*; + + #[test] + fn palette_bmp_roundtrips_its_table_and_pixels() { + // 3 px wide: rows are padded to 4 bytes, so a wrong stride shows in the second row. + let pal = [[255, 0, 0], [0, 0, 255], [10, 20, 30]]; + let idx = [0u8, 1, 2, 2, 1, 0]; + let bytes = encode_bmp_indexed(3, 2, &idx, &pal).unwrap(); + assert_eq!(bmp_palette(&bytes).unwrap().as_deref(), Some(&pal[..]), "the table survives, not a 256-entry grey ramp"); + let img = decode(Format::Bmp, &bytes, &Limits::default()).unwrap(); + assert_eq!(img.dimensions(), (3, 2)); + let rgb = img.convert(ChannelLayout::Rgb, SampleType::U8); + let want: Vec = idx.iter().flat_map(|&i| pal[usize::from(i)]).collect(); + assert_eq!(rgb.data(), want.as_slice()); + } + + #[test] + fn palette_len_follows_the_header() { + let mut bytes = encode_bmp_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]]).unwrap(); + assert_eq!(bmp_palette_len(&bytes), Some(2)); + // biClrUsed = 0 means the full 2^bits table. + bytes[46..50].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(bmp_palette_len(&bytes), Some(256)); + // A short or unknown header leaves the decoded table alone instead of panicking. + assert_eq!(bmp_palette_len(&bytes[..30]), None); + bytes[14..18].copy_from_slice(&7u32.to_le_bytes()); + assert_eq!(bmp_palette_len(&bytes), None); + } + + #[test] + fn palette_bmp_rejects_bad_input() { + let pal = [[0, 0, 0], [255, 255, 255]]; + assert!(encode_bmp_indexed(3, 2, &[0; 5], &pal).is_err(), "size mismatch"); + assert!(encode_bmp_indexed(0, 2, &[], &pal).is_err(), "empty"); + assert!(encode_bmp_indexed(2, 1, &[0, 2], &pal).is_err(), "index past the table"); + assert!(encode_bmp_indexed(1, 1, &[0], &[]).is_err(), "no colours"); + assert!(encode_bmp_indexed(1, 1, &[0], &[[0; 3]; 257]).is_err(), "more than 256 colours"); + } +} diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index b7b6e2a668..f109f62e96 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -56,6 +56,56 @@ pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option bool { + image_webp::WebPDecoder::new(Cursor::new(bytes)).is_ok_and(|d| d.is_animated() && d.num_frames() > 1) +} + +/// Every frame of an animated WebP, or `None` when it is a still one (or an animation of one +/// frame), which [`decode`] reads whole. The frames come composited onto the canvas by +/// `image-webp` (offsets, blending and disposal applied), each a full picture with the file's +/// ICC profile and its duration in milliseconds. +/// +/// The first frame must decode. If a later frame is damaged the frames before it are returned +/// and the first one carries [`DecodeWarning::Truncated`]. The frame count +/// ([`super::gif_frames::MAX_GIF_FRAMES`]) and the total decoded bytes are checked against +/// `limits` before each frame is kept. +pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result>, CodecError> { + use super::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; + if !is_animated(bytes) { + return Ok(None); + } + let (mut dec, w, h, layout) = open(bytes, limits)?; + let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; + let icc = dec.icc_profile().ok().flatten(); + let mut buf = alloc_zeroed::(size)?; + let mut out: Vec = Vec::new(); + let mut damaged = false; + for _ in 0..dec.num_frames() { + if out.len() >= MAX_GIF_FRAMES { + return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); + } + if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); + } + let delay_ms = match dec.read_frame(&mut buf) { + Ok(d) => d, + Err(e) if out.is_empty() => return Err(err(e)), + Err(_) => { + damaged = true; + break; + } + }; + let mut image = Image::from_u8(w, h, layout, buf.clone())?; + image.icc.clone_from(&icc); + out.push(AnimationFrame { image, delay_ms }); + } + if damaged && let Some(first) = out.first_mut() { + first.image.warnings.push(DecodeWarning::Truncated { format: F }); + } + Ok(Some(out)) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index 594735edb4..a6937b571c 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -6,7 +6,7 @@ use std::fmt; use crate::format::{Format, caps}; use crate::image::{ChannelLayout, Image, SampleType}; -use crate::options::EncodeOptions; +use crate::options::{DdsCompression, EncodeOptions}; /// Something that will be lost or altered when writing an image to a format. #[derive(Debug, Clone, PartialEq)] @@ -165,6 +165,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan }, // CIELab has no float form: float Lab is written with 16 bits. Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 }, + // A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB. + Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -211,7 +213,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() { w.push(W::AlphaDiscarded); } - if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() { + let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1); + if binary_alpha && p.layout.has_alpha() && image.has_translucency() { w.push(W::AlphaBinarized); } if sl.is_cmyk() && !p.layout.is_cmyk() { @@ -227,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption // Compression if format == Format::Gif { w.push(W::PaletteQuantized); - } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) { + } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) { w.push(W::LossyCompression); } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index fa2e1e6cb3..a602da57b7 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -19,7 +19,8 @@ pub enum Format { Pcx, /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, - /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3. + /// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip + /// level; writing can add the full mip chain ([`crate::DdsOptions`]). Dds, Qoi, OpenExr, @@ -33,8 +34,12 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, - /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from). Cineon, + /// DICOM single-frame images (uncompressed): read-only. + Dicom, + /// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only. + Iff, } /// What a format can hold **and** what this crate reads/writes for it. @@ -67,24 +72,20 @@ pub struct FormatCaps { /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), + (Format::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."), + ( + Format::Dicom, + "DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or PNG instead.", + ), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", ), - ( - Format::Dds, - "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.", - ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), - ( - Format::Cineon, - "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ - imported frame as DPX, TIFF or EXR instead.", - ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -104,7 +105,7 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L:: impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 20] = [ + pub const ALL: [Format; 22] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -125,6 +126,8 @@ impl Format { Format::Fits, Format::Dpx, Format::Cineon, + Format::Dicom, + Format::Iff, ]; pub fn caps(self) -> FormatCaps { @@ -159,6 +162,8 @@ impl Format { Format::Fits => "FITS", Format::Dpx => "DPX", Format::Cineon => "Cineon", + Format::Dicom => "DICOM", + Format::Iff => "Amiga IFF", } } @@ -186,6 +191,8 @@ impl Format { Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], Format::Cineon => &["cin"], + Format::Dicom => &["dcm", "dicom", "dc3"], + Format::Iff => &["iff", "lbm", "ilbm"], } } @@ -211,6 +218,8 @@ impl Format { Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", Format::Cineon => "image/x-cin", + Format::Dicom => "application/dicom", + Format::Iff => "image/x-iff", } } @@ -255,7 +264,8 @@ pub fn caps(format: Format) -> FormatCaps { Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, - Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base }, + // Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`). + Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -266,7 +276,9 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, - Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, + Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, + Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, + Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base }, } } @@ -324,6 +336,12 @@ pub fn detect(bytes: &[u8]) -> Option { if crate::codecs::dpx::is_cineon(b) { return Some(Format::Cineon); } + if crate::codecs::dicom::is_dicom(b) { + return Some(Format::Dicom); + } + if crate::codecs::iff::looks_like_iff(b) { + return Some(Format::Iff); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 98168d59d3..adc737fee8 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -321,20 +321,75 @@ pub struct ExrPartInfo { impl ExrPartInfo { /// How much this part looks like a colour image the plain [`Image`] decode can show: - /// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// The colour and alpha channels must share a complete layer prefix. The highest-ranked + /// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically. /// The auto-pick takes the highest rank; ties go to the earlier part. pub fn color_rank(&self) -> u8 { - let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n); - if has("R") && has("G") && has("B") { - return if has("A") { 4 } else { 3 }; - } - if has("Y") { - return if has("A") { 2 } else { 1 }; - } - u8::from(self.channels.len() == 1) + exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8) } } +/// One selection policy for header ranking/probing and flat decoding: never synthesize +/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last +/// dot, including nested layers; a leading or trailing dot alone does not name a layer. +pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator) -> Option<(ChannelLayout, Vec)> { + #[derive(Default)] + struct Group { + r: Option, + g: Option, + b: Option, + a: Option, + y: Option, + } + + // Sorted prefixes make rank ties deterministic independently of channel order, with + // the empty (unprefixed) group first. Unsupported channels cannot complete a group. + let mut groups = std::collections::BTreeMap::<&str, Group>::new(); + let mut count = 0usize; + for (index, name) in names.into_iter().enumerate() { + count = count.saturating_add(1); + let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name)); + if !matches!(base, "R" | "G" | "B" | "A" | "Y") { + continue; + } + let group = groups.entry(prefix).or_default(); + let component = match base { + "R" => &mut group.r, + "G" => &mut group.g, + "B" => &mut group.b, + "A" => &mut group.a, + "Y" => &mut group.y, + _ => continue, + }; + component.get_or_insert(index); + } + + let mut best = None; + let mut best_rank = 0; + for group in groups.values() { + let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) { + match group.a { + Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]), + None => (ChannelLayout::Rgb, vec![r, g, b]), + } + } else if let Some(y) = group.y { + match group.a { + Some(a) => (ChannelLayout::GrayA, vec![y, a]), + None => (ChannelLayout::Gray, vec![y]), + } + } else { + continue; + }; + let rank = candidate.0.channels(); + if rank > best_rank { + best = Some(candidate); + best_rank = rank; + } + } + best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0]))) +} + /// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`). pub(crate) fn base_channel_name(name: &str) -> &str { name.rsplit('.').next().unwrap_or(name) @@ -399,6 +454,9 @@ pub struct Image { data: Vec, /// Embedded ICC profile (must describe `layout`'s colour model). pub icc: Option>, + /// The file's CICP colour description (a HEIF `nclx`), when it has one. Only an image whose + /// `icc` is `None` relies on it. + pub cicp: Option, pub meta: Metadata, /// What the decoder noticed about the file: empty for a complete, single image and for /// images built in memory. Encoders ignore it. @@ -413,7 +471,7 @@ impl Image { /// A zero-filled image, or an error if the byte size overflows `usize`. pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result { let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; - Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() }) + Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() }) } /// Wrap raw interleaved, native-endian bytes. @@ -422,7 +480,7 @@ impl Image { if data.len() != expected { return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len()))); } - Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() }) + Ok(Image { width, height, layout, sample, data, icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() }) } pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec) -> Result { @@ -591,6 +649,7 @@ impl Image { sample, data, icc: if layout.same_model(self.layout) { self.icc.clone() } else { None }, + cicp: if layout.same_model(self.layout) { self.cicp } else { None }, meta: self.meta.clone(), warnings: self.warnings.clone(), } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..cbfc550355 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -13,6 +13,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod cicp; mod codecs; mod error; pub mod exif_color; @@ -25,10 +26,10 @@ pub mod orientation; pub mod resolution; pub mod source_settings; pub mod web; - +pub use crate::cicp::Cicp; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; +pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; @@ -38,13 +39,15 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{ + DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression, +}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -81,6 +84,8 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), Format::Cineon => dpx::decode_cineon(bytes, l), + Format::Dicom => dicom::decode(bytes, l), + Format::Iff => iff::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -126,11 +131,23 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) } +/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the +/// entry its Graphic Control Extension makes transparent, read without decoding pixels. +pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { + crate::codecs::gif_palette::palette(bytes) +} + /// Returns the embedded colour table for an indexed BMP, if it has one. pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) } +/// Encodes `indices` (row-major, one byte per pixel) as an 8-bit palette BMP with the colour +/// table `palette` (1 to 256 entries). Used for Indexed Color documents; BMP has no transparency. +pub fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result, CodecError> { + via_image::encode_bmp_indexed(width, height, indices, palette) +} + /// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the /// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or /// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens. @@ -152,6 +169,23 @@ pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result Result, CodecError> { + match crate::codecs::webp::decode_frames(bytes, &opts.limits)? { + Some(frames) => Ok(frames), + None => Ok(vec![AnimationFrame { image: decode_as_with(Format::WebP, bytes, opts)?, delay_ms: 0 }]), + } +} + +/// Whether `bytes` is an animated WebP of more than one frame (which [`decode`] opens as its +/// first frame, with a warning, and [`decode_webp_frames`] reads whole). +pub fn webp_is_animated(bytes: &[u8]) -> bool { + crate::codecs::webp::is_animated(bytes) +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { @@ -164,6 +198,18 @@ pub fn tiff_page_orientation(bytes: &[u8], page: usize) -> u16 { tiff::orientation(bytes, Some(page)) } +/// The EXIF block (`Metadata::exif`) of a camera raw built on TIFF (NEF, ARW, CR2, DNG, +/// Panasonic RW2), for the image developed from it: IFD0's descriptive tags (make, model, +/// date…) and the Exif and GPS directories, as a TIFF opens with. Orientation is not part of +/// it, nor the Exif directory's pixel dimensions (the sensor's or the preview's size). When those +/// were all its Exif directory held, the block is IFD0's tags alone, so the camera's make and +/// model are still kept. `None` when the file is not built on a classic TIFF stream or has none +/// of these tags. +pub fn raw_exif(bytes: &[u8]) -> Option> { + let dirs = crate::codecs::tiff_exif::read_raw(bytes).filter(|d| d.has_directories() || !d.ifd0.is_empty())?; + Some(crate::codecs::tiff_exif::block(&dirs)) +} + /// The limits check and the orientation, shared by every decoder. fn finish_decode(img: Image, o: u16, opts: &DecodeOptions) -> Result { let l = &opts.limits; @@ -252,10 +298,12 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), - Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), + Format::Dds => dds::encode(image, plan, opts), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), - Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), + Format::Cineon => dpx::encode_cineon(image, plan), + Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")), + Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index eee5b38c0a..1f7b249722 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -100,6 +100,34 @@ pub enum PngCompression { Best, } +/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngStereo { + /// Side by side with the right-eye view first (cross-eyed viewing, mode 0). + CrossFuse, + /// Side by side with the left-eye view first (parallel viewing, mode 1). + DivergingFuse, +} + +impl PngStereo { + /// The view for an `sTER` mode byte; reserved and unknown values are `None`. + pub fn from_mode(mode: u8) -> Option { + match mode { + 0 => Some(Self::CrossFuse), + 1 => Some(Self::DivergingFuse), + _ => None, + } + } + + /// The `sTER` mode byte. + pub fn mode(self) -> u8 { + match self { + Self::CrossFuse => 0, + Self::DivergingFuse => 1, + } + } +} + /// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the /// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -111,6 +139,37 @@ pub enum TiffCompression { PackBits, } +/// JPEG scan layout: Photoshop's JPEG Options › Format Options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum JpegFormat { + /// Baseline ("Standard"): one sequential scan with the standard Huffman tables. + #[default] + Baseline, + /// Baseline Optimized: one sequential scan with Huffman tables fitted to the image. Only at + /// 4:4:4 for now: a 4:2:0 (subsampled RGB) baseline scan keeps the standard tables, because + /// fitted tables make jpeg-encoder write one scan per component, which zune-jpeg 0.5.15 (our + /// decoder) reads back wrongly when chroma is subsampled (see `web::encode_jpeg_rgb8`). + Optimized, + /// Progressive: the image refines over several scans as it loads. + Progressive, +} + +impl JpegFormat { + /// `"baseline"`, `"optimized"` or `"progressive"`, as parameters and scripts spell it. + pub fn name(self) -> &'static str { + match self { + JpegFormat::Baseline => "baseline", + JpegFormat::Optimized => "optimized", + JpegFormat::Progressive => "progressive", + } + } + + /// The inverse of [`Self::name`]. + pub fn parse(s: &str) -> Option { + [JpegFormat::Baseline, JpegFormat::Optimized, JpegFormat::Progressive].into_iter().find(|f| f.name() == s) + } +} + /// OpenEXR compression (lossless methods only). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ExrCompression { @@ -154,6 +213,61 @@ impl DpxBits { } } +/// How a DDS texture stores its pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DdsCompression { + /// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or + /// without alpha for grayscale. Lossless. + #[default] + None, + /// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become + /// transparent black. + Bc1, + /// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel. + Bc2, + /// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha. + Bc3, +} + +impl DdsCompression { + pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3]; + + /// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`. + pub fn name(self) -> &'static str { + match self { + DdsCompression::None => "none", + DdsCompression::Bc1 => "bc1", + DdsCompression::Bc2 => "bc2", + DdsCompression::Bc3 => "bc3", + } + } + + /// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`, + /// `dxt5`), in any case. Anything else is `None`. + pub fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "none" => Some(DdsCompression::None), + "bc1" | "dxt1" => Some(DdsCompression::Bc1), + "bc2" | "dxt3" => Some(DdsCompression::Bc2), + "bc3" | "dxt5" => Some(DdsCompression::Bc3), + _ => None, + } + } + + /// A block compression (lossy), not uncompressed pixels. + pub fn is_block(self) -> bool { + self != DdsCompression::None + } +} + +/// What a DDS file is written with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DdsOptions { + pub compression: DdsCompression, + /// Write the full mip chain down to 1×1, each level a box filter of the one above. + pub mipmaps: bool, +} + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -161,9 +275,14 @@ pub struct EncodeOptions { pub jpeg_quality: u8, /// JPEG 4:2:0 chroma subsampling (`false` = 4:4:4). pub jpeg_chroma_subsampling: bool, + /// JPEG scan layout (baseline, baseline optimized or progressive). + pub jpeg_format: JpegFormat, pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, + /// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6), + /// so a stereo PNG keeps its mark when saved again (#6063). + pub png_stereo: Option, /// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see /// `codecs::vp8`) with [`Self::webp_quality`]. pub webp_lossless: bool, @@ -176,6 +295,8 @@ pub struct EncodeOptions { pub exr_compression: ExrCompression, /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. pub dpx_bits: Option, + /// DDS compression and mip chain. + pub dds: DdsOptions, /// Embed the ICC profile when the format supports it. pub embed_icc: bool, /// Embed EXIF/XMP/DPI/text when the format supports it. @@ -187,14 +308,17 @@ impl Default for EncodeOptions { EncodeOptions { jpeg_quality: 90, jpeg_chroma_subsampling: true, + jpeg_format: JpegFormat::Baseline, png_compression: PngCompression::Default, png_interlaced: false, + png_stereo: None, webp_lossless: true, webp_quality: 80, tiff_compression: TiffCompression::None, tiff_bigtiff: false, exr_compression: ExrCompression::Zip16, dpx_bits: None, + dds: DdsOptions::default(), embed_icc: true, embed_metadata: true, } diff --git a/crates/codecs/src/resolution.rs b/crates/codecs/src/resolution.rs index 049a00eaac..a787cacbc3 100644 --- a/crates/codecs/src/resolution.rs +++ b/crates/codecs/src/resolution.rs @@ -159,23 +159,51 @@ pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> { ppi_pair(x * scale, y * scale) } -/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when there is -/// nothing to change: no resolution entries, already that resolution, or not parseable. +/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no +/// resolution entries, already records that resolution, or cannot be parsed. /// -/// Every well-formed XResolution / YResolution (one RATIONAL) gets the new value in place and -/// every well-formed ResolutionUnit (one SHORT) becomes 2 (inch); all other bytes are kept. -/// A resolution entry that can't be rewritten safely (another type or count, or a value outside -/// the block) is removed from IFD0 instead, so no stale value survives: the later entries move -/// up, the entry count and next-IFD pointer follow, and the freed bytes at the table's end are -/// zeroed. Nothing else moves, so every offset in the block stays valid. +/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place, +/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are +/// removed from IFD0 instead, so no stale value survives; all other bytes are kept. pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> { + exif_with_resolution_inner(exif, ppi, false) +} + +/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none. +/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which +/// cannot carry a JFIF density marker. +pub(crate) fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> { + exif_with_resolution_inner(exif, ppi, true) +} + +/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed. +fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> { if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() { return Cow::Borrowed(exif); } + if exif.is_empty() { + if !add_missing { + return Cow::Borrowed(exif); + } + // An image with DPI but no source EXIF still needs a valid EXIF block when the output + // format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker). + return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned()); + } let body = tiff_body(exif); let prefix = exif.len() - body.len(); let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) }; - if ifd.fields.is_empty() || exif_resolution(body).is_some_and(|r| same(r, ppi)) { + if exif_resolution(body).is_some_and(|r| same(r, ppi)) { + return Cow::Borrowed(exif); + } + let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none() + || ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none(); + if add_missing && missing_xy { + let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) }; + let mut full = exif.get(..prefix).unwrap_or_default().to_vec(); + full.extend_from_slice(&body); + return Cow::Owned(full); + } + if ifd.fields.is_empty() { return Cow::Borrowed(exif); } let order = ifd.order; @@ -207,6 +235,98 @@ pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> { if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) } } +/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original +/// bytes and offsets, since the TIFF block's existing data remains where it was. +fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option> { + let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) }; + let mut entries = Vec::with_capacity(ifd.count.saturating_add(3)); + for index in 0..ifd.count { + let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?; + let tag = ifd.order.u16(body, entry)?; + if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) { + continue; + } + entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec())); + } + let count = entries.len().checked_add(3)?; + if !ifd.big && count > usize::from(u16::MAX) { + return None; + } + + let new_ifd = align_up(body.len(), alignment)?; + let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?; + let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?; + let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?; + let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?; + let y_rational_at = rational_at.checked_add(8)?; + if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) { + return None; + } + + entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?)); + entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?)); + entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?)); + entries.sort_by_key(|(tag, _)| *tag); + + let mut out = body.to_vec(); + out.resize(new_ifd, 0); + if ifd.big { + out.extend_from_slice(&put_u64(ifd.order, count as u64)); + } else { + out.extend_from_slice(&put_u16(ifd.order, count as u16)); + } + for (_, entry) in entries { + out.extend_from_slice(&entry); + } + out.extend_from_slice(next_ifd); + out.resize(rational_at, 0); + let (xn, xd) = rational(ppi.0); + let (yn, yd) = rational(ppi.1); + out.extend_from_slice(&put_u32(ifd.order, xn)); + out.extend_from_slice(&put_u32(ifd.order, xd)); + out.extend_from_slice(&put_u32(ifd.order, yn)); + out.extend_from_slice(&put_u32(ifd.order, yd)); + + let offset_at: usize = if ifd.big { 8 } else { 4 }; + if ifd.big { + out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64)); + } else { + out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?)); + } + Some(out) +} + +/// One IFD entry. Rational fields store an offset; the SHORT unit is inline. +fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option> { + let entry_bytes = if big { 20 } else { 12 }; + let count_at = 4; + let value_at = if big { 12 } else { 8 }; + let mut entry = vec![0; entry_bytes]; + entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag)); + entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL })); + if big { + entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1)); + if tag == TAG_RESOLUTION_UNIT { + entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16)); + } else { + entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value)); + } + } else { + entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1)); + if tag == TAG_RESOLUTION_UNIT { + entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16)); + } else { + entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?)); + } + } + Some(entry) +} + +/// Round `value` up to the next `alignment` boundary, returning `None` on overflow. +fn align_up(value: usize, alignment: usize) -> Option { + value.checked_add((alignment - value % alignment) % alignment) +} + fn put_u16(order: Order, v: u16) -> [u8; 2] { match order { Order::Little => v.to_le_bytes(), @@ -309,8 +429,14 @@ fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> { continue; }; let start = xmp.len() - value.len(); - let Some(len) = value.find(close) else { continue }; + // Advance past this value before searching for the next key. Without this, `from` + // only moved past the key text, so property-like text repeated inside an unrelated + // attribute re-ran this full-suffix `find(close)` for every occurrence, which is + // quadratic in the packet size. A missing close means the value is unterminated, so + // no later value can parse either; stop scanning in that case. + let Some(len) = value.find(close) else { break }; out.push((start, start + len)); + from = start + len + close.len_utf8(); } out } @@ -407,13 +533,25 @@ fn exif_without_thumbnail(exif: &[u8]) -> Option> { /// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and, /// when `ppi` is known, the resolution (see [`exif_with_resolution`]). pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> { + export_exif_inner(exif, ppi, false) +} + +/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution +/// entries to valid metadata when necessary. +pub(crate) fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> { + export_exif_inner(exif, ppi, true) +} + +/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags. +fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> { let up = upright_exif(exif); let up = match exif_without_thumbnail(&up) { Some(v) => Cow::Owned(v), None => up, }; let Some(ppi) = ppi else { return up }; - if let Cow::Owned(v) = exif_with_resolution(&up, ppi) { + let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) }; + if let Cow::Owned(v) = result { return Cow::Owned(v); } up diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 584ceb9d6e..dfd0cd9b90 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { } /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and -/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. +/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8( optimized: bool, icc: Option<&[u8]>, dpi: Option, + exif: Option<&[u8]>, xmp: Option<&str>, ) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); @@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8( let v = d.round().min(65535.0) as u16; enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v }); } + let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d)); + // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it. + if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { + enc.add_app_segment(1, &seg).map_err(e)?; + } if let Some(x) = xmp { // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))); + let x = crate::resolution::export_xmp(x, ppi); let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); seg.extend_from_slice(x.as_bytes()); enc.add_app_segment(1, &seg).map_err(e)?; @@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8( Ok(out) } +/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment). +pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { + crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -359,7 +371,7 @@ mod tests { let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect(); for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] { for progressive in [false, true] { - let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap(); assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}"); } } @@ -428,8 +440,8 @@ mod tests { #[test] fn progressive_jpeg_decodes() { let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect(); - let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("")).unwrap(); - let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap(); + let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("")).unwrap(); + let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap(); assert_ne!(base, prog); assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("")); // SOF2 marks a progressive frame. @@ -442,7 +454,7 @@ mod tests { fn web_jpeg_xmp_resolution_follows_the_density() { let rgb = vec![100u8; 8 * 8 * 3]; let xmp = r#""#; - let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap(); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap(); let img = crate::decode(&out).unwrap(); assert_eq!(img.meta.dpi, Some((72.0, 72.0))); assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0))); @@ -461,7 +473,7 @@ mod subsampling_tests { let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect(); for q in [30, 60, 95] { for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] { - let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap(); let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8); let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap(); assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}"); diff --git a/crates/codecs/tests/camera_raw.rs b/crates/codecs/tests/it/camera_raw.rs similarity index 100% rename from crates/codecs/tests/camera_raw.rs rename to crates/codecs/tests/it/camera_raw.rs diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs similarity index 100% rename from crates/codecs/tests/common/mod.rs rename to crates/codecs/tests/it/common/mod.rs diff --git a/crates/codecs/tests/common/tiffgen.rs b/crates/codecs/tests/it/common/tiffgen.rs similarity index 100% rename from crates/codecs/tests/common/tiffgen.rs rename to crates/codecs/tests/it/common/tiffgen.rs diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/it/dds.rs similarity index 72% rename from crates/codecs/tests/dds.rs rename to crates/codecs/tests/it/dds.rs index 766555f6e5..1f4091d430 100644 --- a/crates/codecs/tests/dds.rs +++ b/crates/codecs/tests/it/dds.rs @@ -6,6 +6,7 @@ const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; const DDPF_RGB: u32 = 0x40; const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSD_PITCH: u32 = 0x8; /// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { @@ -31,6 +32,11 @@ fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) } +fn set_pitch(b: &mut [u8], pitch: u32) { + b[8..12].copy_from_slice(&(0x1007 | DDSD_PITCH).to_le_bytes()); + b[20..24].copy_from_slice(&pitch.to_le_bytes()); +} + /// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { let mut b = compressed(width, height, b"DX10"); @@ -59,11 +65,12 @@ const WHITE: u16 = 0xffff; const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0]; #[test] -fn dds_is_readable_but_not_writable() { +fn dds_is_readable_and_writable() { assert_eq!(from_extension("texture.DDS"), Some(Format::Dds)); - assert!(caps(Format::Dds).read && !caps(Format::Dds).write); - let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); - assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. }))); + assert!(caps(Format::Dds).read && caps(Format::Dds).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap(); + let b = encode(&img, Format::Dds, &Default::default()).unwrap(); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]); } #[test] @@ -169,6 +176,101 @@ fn decodes_packed_masks_and_luminance() { assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); } +#[test] +fn decodes_padded_rgb_rows() { + // DWORD-aligned 24-bit rows: the fourth byte is padding, not the next pixel. + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout()), ((1, 2), ChannelLayout::Rgb)); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]); + + // Padding can contain whole pixels; only the declared width is decoded. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + set_pitch(&mut b, 8); + for v in [RED, GREEN, WHITE, WHITE, BLUE, BLACK, WHITE, WHITE] { + b.extend(v.to_le_bytes()); + } + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]); +} + +#[test] +fn decodes_padded_luminance_rows() { + let mut b = header(2, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Gray, &[10u8, 20, 30, 40][..])); + + let mut b = header(1, 2, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_padded_dx10_rows() { + let mut b = dx10(1, 2, 28, 3, 0, 1); // R8G8B8A8_UNORM + set_pitch(&mut b, 8); + b.extend([1, 2, 3, 4, 99, 98, 97, 96, 5, 6, 7, 8, 95, 94, 93, 92]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8]); +} + +#[test] +fn ignores_pitch_without_the_pitch_flag() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + b[20..24].copy_from_slice(&u32::MAX.to_le_bytes()); + b.extend([255, 0, 0, 0, 255, 0]); + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); + + set_pitch(&mut b, 3); // An explicitly tightly packed row is also valid. + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); +} + +#[test] +fn compressed_linear_size_is_not_a_row_pitch() { + let mut b = compressed(4, 8, b"DXT1"); + b[8..12].copy_from_slice(&(0x1007u32 | 0x8_0000).to_le_bytes()); // DDSD_LINEARSIZE + b[20..24].copy_from_slice(&16u32.to_le_bytes()); // Two 8-byte block rows. + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert!(im.data()[..64].as_chunks::<4>().0.iter().all(|p| p == &[255, 0, 0, 255])); + assert!(im.data()[64..].as_chunks::<4>().0.iter().all(|p| p == &[0, 0, 255, 255])); +} + +#[test] +fn rejects_pitch_smaller_than_a_pixel_row() { + for pitch in [0, 1, 2] { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, pitch); + b.extend([255, 0, 0, 0, 255, 0]); + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })), "pitch {pitch}"); + } +} + +#[test] +fn rejects_truncated_padded_rows() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + assert!(decode(&b).is_ok()); + for n in 128..b.len() { + assert!(matches!(decode(&b[..n]), Err(CodecError::Malformed { .. })), "decoded {n} of {} bytes", b.len()); + } +} + +#[test] +fn rejects_huge_pitch_without_allocating() { + let mut b = header(1, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, u32::MAX); + b.extend([10, 20]); + // On 32-bit targets pitch * height overflows; on 64-bit the payload is too short. + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. }))); +} + #[test] fn decodes_dx10_headers() { let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM diff --git a/crates/codecs/tests/it/dds_write.rs b/crates/codecs/tests/it/dds_write.rs new file mode 100644 index 0000000000..e51e21ceda --- /dev/null +++ b/crates/codecs/tests/it/dds_write.rs @@ -0,0 +1,375 @@ +//! Writing DDS textures: every storage read back through the reader, the header fields, sizes +//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps. + +use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with}; + +const DDSD_PITCH: u32 = 0x8; +const DDSD_MIPMAPCOUNT: u32 = 0x2_0000; +const DDSD_LINEARSIZE: u32 = 0x8_0000; +const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT +const DDSCAPS_COMPLEX: u32 = 0x8; +const DDSCAPS_TEXTURE: u32 = 0x1000; +const DDSCAPS_MIPMAP: u32 = 0x40_0000; +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; + +const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3]; + +fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions { + EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() } +} + +fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the +/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which +/// is what a colour block stores. +fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image { + let ch = layout.channels(); + let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8; + let mut data = Vec::new(); + for y in 0..h { + for x in 0..w { + let r = at(x, w); + let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)]; + match ch { + 1 => data.push(rgba[0]), + 2 => data.extend([rgba[0], rgba[3]]), + _ => data.extend(&rgba[..ch]), + } + } + } + Image::from_u8(w, h, layout, data).unwrap() +} + +/// The same RGBA image, fully opaque. +fn opaque(img: &Image) -> Image { + let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap() +} + +/// Largest difference in channel `c` (of four) between RGBA images. +fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 { + a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0) +} + +/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under +/// half opacity into transparent black). +fn max_colour_diff(a: &Image, b: &Image) -> u8 { + a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0) +} + +/// A 2D gradient: red across, green down and blue along the diagonal, opaque. +fn gradient(w: u32, h: u32) -> Image { + let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect(); + Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap() +} + +/// PSNR of the colour (not the alpha) of two RGBA images. +fn psnr(a: &Image, b: &Image) -> f64 { + let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::>(); + let (a, b) = (rgb(a), rgb(b)); + let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() } +} + +/// Bytes of one level as a reader computes them from the header's pixel format. +fn level_bytes(file: &[u8], w: usize, h: usize) -> usize { + if le32(file, 80) & DDPF_FOURCC != 0 { + let block = if &file[84..88] == b"DXT1" { 8 } else { 16 }; + w.div_ceil(4) * h.div_ceil(4) * block + } else { + w * h * le32(file, 88) as usize / 8 + } +} + +/// Mip level `i` of a written texture, cut out as a single-level texture and decoded. +fn level(file: &[u8], i: u32) -> Image { + let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize); + let size = |i: u32| ((w >> i).max(1), (h >> i).max(1)); + let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::(); + let (lw, lh) = size(i); + let mut b = file[..128].to_vec(); + b[12..16].copy_from_slice(&(lh as u32).to_le_bytes()); + b[16..20].copy_from_slice(&(lw as u32).to_le_bytes()); + b[28..32].copy_from_slice(&1u32.to_le_bytes()); + // The pitch field is a row stride for uncompressed textures and the level size for BC. + let pitch = if le32(file, 80) & DDPF_FOURCC != 0 { level_bytes(file, lw, lh) } else { lw * le32(file, 88) as usize / 8 }; + b[20..24].copy_from_slice(&(pitch as u32).to_le_bytes()); + b.extend(&file[start..start + level_bytes(file, lw, lh)]); + decode(&b).unwrap() +} + +#[test] +fn uncompressed_textures_round_trip_every_layout_exactly() { + for (layout, pf_flags, bits, masks) in [ + (ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]), + (ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]), + (ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]), + (ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]), + ] { + for (w, h) in [(1, 1), (5, 3), (17, 9)] { + let img = ramp(w, h, layout); + let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap(); + let back = decode(&b).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}"); + assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}"); + + assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32)); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count"); + assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w)); + assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}"); + assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE)); + assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits)); + assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks); + assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize); + } + } +} + +#[test] +fn block_compressed_textures_round_trip_through_the_reader() { + for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] { + // Not multiples of four, and the 1×1 and 2×2 textures smaller than one block. + for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] { + let img = ramp(w, h, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap(); + let linear = w.div_ceil(4) * h.div_ceil(4) * block; + assert_eq!(&b[84..88], fourcc); + assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0)); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch"); + assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size"); + assert_eq!(b.len(), 128 + linear as usize); + + let back = decode(&b).unwrap(); + assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba)); + // Steep ramps across tiny textures put three colours where a block interpolates at + // thirds, so this bound is loose; it still catches swapped channels or misplaced + // blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1 + // block with transparent texels has only three colours, so BC1's colours are + // checked on the opaque ramp. + let (src, back_colour) = if compression == DdsCompression::Bc1 { + let o = opaque(&img); + let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + (o, back) + } else { + (img.clone(), back.clone()) + }; + let colour = max_colour_diff(&src, &back_colour); + assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}"); + let alpha = max_diff_channel(&img, &back, 3); + match compression { + DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })), + _ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"), + } + } + } +} + +#[test] +fn gray_and_rgb_sources_are_written_as_rgba_blocks() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] { + for compression in BLOCKS { + let img = ramp(8, 8, layout); + let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let want = img.convert(ChannelLayout::Rgba, img.sample_type()); + assert_eq!(back.layout(), ChannelLayout::Rgba); + let colour = max_colour_diff(&want, &back); + assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}"); + } + } +} + +#[test] +fn block_compression_error_on_gradients_is_bounded() { + let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64)); + for compression in BLOCKS { + let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane)); + let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back)); + let p = psnr(&plane, &plane_back); + let alpha = max_diff_channel(&line, &line_back, 3); + // Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in + // red and blue). A gradient in two directions is not on a line: the spread off a + // block's best line alone limits this one to about 40 dB, and RGB565 costs a little + // more (the encoder measures 38.6 dB and 9 levels at most). + assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}"); + assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}"); + assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB"); + match compression { + DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })), + // Four bits: within half a step of 17. + DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"), + // Eight values across a block's few levels of alpha. + _ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"), + } + } +} + +#[test] +fn solid_and_cutout_textures_keep_their_values() { + // RGB565-exact colours and BC2's 4-bit alpha steps come back exactly. + let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap(); + for compression in BLOCKS { + let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] }; + assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]); + } + // A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque. + let cutout: Vec = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect(); + let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap(); + for compression in BLOCKS { + let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}"); + if compression == DdsCompression::Bc1 { + assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0])); + } + } +} + +#[test] +fn mipmaps_write_the_full_chain_box_filtered() { + // 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels. + let img = ramp(16, 8, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT); + assert_eq!(le32(&b, 28), 5); + assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP); + assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1)); + assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level"); + let mut above = img.clone(); + for i in 1..5 { + let got = level(&b, i); + assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1))); + let want = box_filter(&above); + assert_eq!(got.data(), want.data(), "level {i}"); + above = got; + } + // 1×1 has a single level. + let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0)); +} + +/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers, +/// and the last column and row of an odd-sized level take in the leftover pixel too. +fn box_filter(src: &Image) -> Image { + let (w, h) = (src.width() as usize, src.height() as usize); + let (nw, nh) = ((w / 2).max(1), (h / 2).max(1)); + let ch = src.layout().channels(); + let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 }); + let mut out = Vec::new(); + for y in 0..nh { + for x in 0..nw { + let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h)); + for c in 0..ch { + let values: Vec = + (y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect(); + let n = values.len() as u32; + out.push(((values.iter().sum::() + n / 2) / n) as u8); + } + } + } + Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap() +} + +#[test] +fn odd_sized_mip_chains_keep_every_pixel() { + // 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows. + let img = ramp(5, 3, ChannelLayout::Gray); + let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!(le32(&b, 28), 3); + assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1); + let second = level(&b, 1); + assert_eq!(second.data(), box_filter(&img).data()); + let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]); + let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum(); + assert_eq!(u32::from(second.data()[1]), (right + 4) / 9); +} + +#[test] +fn block_compressed_mip_chains_have_a_block_per_small_level() { + for compression in BLOCKS { + let block = if compression == DdsCompression::Bc1 { 8 } else { 16 }; + let img = ramp(12, 6, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap(); + // 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks. + assert_eq!(le32(&b, 28), 4); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT); + assert_eq!(le32(&b, 20), 6 * block); + assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize); + for i in 0..4 { + let got = level(&b, i); + assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}"); + } + } +} + +#[test] +fn strips_and_tiny_textures_encode_with_every_option() { + for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] { + for compression in DdsCompression::ALL { + for mipmaps in [false, true] { + let img = ramp(w, h, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}")); + assert_eq!(decode(&b).unwrap().dimensions(), (w, h)); + } + } + } +} + +#[test] +fn fidelity_warnings_follow_the_compression() { + let translucent = ramp(8, 8, ChannelLayout::Rgba); + let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false)); + assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing"); + for c in BLOCKS { + assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}"); + } + assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized)); + assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized)); +} + +#[test] +fn compression_names_round_trip() { + for c in DdsCompression::ALL { + assert_eq!(DdsCompression::from_name(c.name()), Some(c)); + } + assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3)); + assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1)); + for bad in ["", "bc7", "dxt2", "uncompressed "] { + assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}"); + } +} + +/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release: +/// +/// ```sh +/// cargo test --release -p photocraft-codecs --test it -- dds_write:: --ignored --nocapture +/// ``` +#[test] +#[ignore] +fn dds_encode_timings_on_30_mp() { + let (w, h) = (6336u32, 4752u32); + let mut s = 0x1234_5678u32; + let mut data = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]); + } + } + let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap(); + for compression in DdsCompression::ALL { + for mipmaps in [false, true] { + let t = std::time::Instant::now(); + let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap(); + eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20); + } + } +} diff --git a/crates/codecs/tests/decode_warnings.rs b/crates/codecs/tests/it/decode_warnings.rs similarity index 99% rename from crates/codecs/tests/decode_warnings.rs rename to crates/codecs/tests/it/decode_warnings.rs index 5fc310104a..82bf75b57d 100644 --- a/crates/codecs/tests/decode_warnings.rs +++ b/crates/codecs/tests/it/decode_warnings.rs @@ -1,7 +1,7 @@ //! Decode warnings: a file that holds more than was decoded (#523: animations, multi-page //! TIFF) or less than it should (#518: truncated JPEG) says so; complete single images don't. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/it/deep_exr.rs similarity index 100% rename from crates/codecs/tests/deep_exr.rs rename to crates/codecs/tests/it/deep_exr.rs diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/it/detect_caps.rs similarity index 84% rename from crates/codecs/tests/detect_caps.rs rename to crates/codecs/tests/it/detect_caps.rs index f2191e05f1..8db608de12 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/it/detect_caps.rs @@ -1,7 +1,7 @@ //! Magic-number detection, extensions, and the capability table //! (including the symmetric read/write guarantee). -mod common; +use crate::common; use common::*; use photocraft_codecs::*; @@ -60,6 +60,7 @@ fn default_build_formats_all_symmetric() { Format::Hdr, Format::Fits, Format::Dpx, + Format::Cineon, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); @@ -102,7 +103,9 @@ fn caps_are_internally_consistent() { let c = caps(f); assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}"); assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts"); - assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/")); + // DICOM's registered media type is `application/dicom`; every other format is an image. + let mime_ok = f.mime_type().starts_with("image/") || (f == Format::Dicom && f.mime_type() == "application/dicom"); + assert!(!f.extensions().is_empty() && !f.name().is_empty() && mime_ok, "{f:?}"); } } @@ -192,11 +195,27 @@ fn detect_magic_cineon() { b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); assert_eq!(detect(&b), Some(Format::Cineon)); assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); - assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); - assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + assert!(caps(Format::Cineon).read && caps(Format::Cineon).write, "Cineon is read and written"); + assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); // A bare magic is an error, never a panic. assert!(decode_as(Format::Cineon, &b).is_err()); } + +#[test] +fn detect_magic_iff() { + // FORM + a four-byte length + the bitmap type. + assert_eq!(detect(b"FORM\0\0\0\x14ILBM"), Some(Format::Iff)); + assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff)); + assert_eq!(from_extension("art.LBM"), Some(Format::Iff)); + assert_eq!(from_extension("art.ilbm"), Some(Format::Iff)); + assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only"); + assert!(caps(Format::Iff).alpha); + assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba)); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff)); + // A bare FORM without a bitmap type is not IFF. + assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None); + assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err()); +} #[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); @@ -204,6 +223,19 @@ fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand"); assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif"); } +#[test] +fn detect_magic_dicom() { + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + assert_eq!(detect(&b), Some(Format::Dicom)); + assert_eq!(from_extension("scan.DCM"), Some(Format::Dicom)); + assert_eq!(from_extension("scan.dc3"), Some(Format::Dicom)); + assert!(caps(Format::Dicom).read && !caps(Format::Dicom).write); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Dicom)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Dicom, &b).is_err()); +} + #[test] fn detect_tga_heuristic() { // Uncompressed true-colour 2x2, 24 bpp. diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/it/dpx.rs similarity index 76% rename from crates/codecs/tests/dpx.rs rename to crates/codecs/tests/it/dpx.rs index 8fdeb398c3..6fbfb2db7b 100644 --- a/crates/codecs/tests/dpx.rs +++ b/crates/codecs/tests/it/dpx.rs @@ -384,3 +384,95 @@ fn unsupported_and_damaged_files_error_instead_of_panicking() { assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); } } + +// ---------------------------------------------------------------- Cineon (Kodak .cin) + +/// A Cineon 4.5 file built field by field: the 1024-byte generic header, a 1024-byte film header, +/// then `codes` (interleaved, top line first) three 10-bit values to a big-endian word, left +/// justified, each line on a word boundary. +fn cineon_bytes(channels: u8, width: u32, height: u32, codes: &[u16]) -> Vec { + let mut b = vec![0u8; 2048]; + b[0..4].copy_from_slice(&0x802A_5FD7u32.to_be_bytes()); + b[4..8].copy_from_slice(&2048u32.to_be_bytes()); + b[193] = channels; + b[198] = 10; + b[200..204].copy_from_slice(&width.to_be_bytes()); + b[204..208].copy_from_slice(&height.to_be_bytes()); + for line in codes.chunks(width as usize * usize::from(channels)) { + for chunk in line.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (22 - 10 * i as u32))); + b.extend(w.to_be_bytes()); + } + } + b +} + +/// Code values spread over 0..=1023, ends included. +fn cineon_codes(n: usize) -> Vec { + (0..n).map(|i| if i == 1 { 1023 } else { ((i as u64 * 2654435761) % 1024) as u16 }).collect() +} + +/// Reads back what we wrote: (channels, width, height, code values), from the header's own fields. +fn cineon_written(b: &[u8]) -> (u8, u32, u32, Vec) { + let off = be32_at(b, 4) as usize; + let (nc, w, h) = (b[193], be32_at(b, 200), be32_at(b, 204)); + let per_line = w as usize * usize::from(nc); + let stride = per_line.div_ceil(3) * 4; + let mut codes = Vec::new(); + for y in 0..h as usize { + let line = &b[off + y * stride..]; + codes.extend((0..per_line).map(|i| ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16)); + } + (nc, w, h, codes) +} + +#[test] +fn cineon_frames_saved_as_cineon_keep_their_code_values_and_reopen_identically() { + // Widths that leave partly filled words at the end of each line. + for (channels, layout) in [(1u8, ChannelLayout::Gray), (3, ChannelLayout::Rgb)] { + let codes = cineon_codes(5 * 3 * usize::from(channels)); + let img = decode(&cineon_bytes(channels, 5, 3, &codes)).unwrap(); + assert_eq!(img.layout(), layout); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap_or_else(|e| panic!("{layout:?}: {e}")); + assert_eq!(detect(&out), Some(Format::Cineon), "{layout:?}"); + assert_eq!(cineon_written(&out), (channels, 5, 3, codes), "{layout:?}"); + assert_eq!(decode(&out).unwrap(), img, "{layout:?}"); + } +} + +#[test] +fn cineon_header_follows_the_kodak_layout() { + let img = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap(); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap(); + let f32_at = |at: usize| f32::from_bits(be32_at(&out, at)); + // Generic and film header lengths, user data, total size, version. + assert_eq!((be32_at(&out, 4), be32_at(&out, 8), be32_at(&out, 12), be32_at(&out, 16)), (2048, 1024, 1024, 0)); + assert_eq!(be32_at(&out, 20) as usize, out.len()); + assert_eq!(&out[24..29], b"V4.5\0"); + // Upright, three channels, each the universal metric's red, green, blue at 10 bits. + assert_eq!((out[192], out[193]), (0, 3)); + for (i, colour) in [1u8, 2, 3].into_iter().enumerate() { + let c = 196 + 28 * i; + assert_eq!(&out[c..c + 3], &[0, colour, 10], "channel {i}"); + assert_eq!((be32_at(&out, c + 4), be32_at(&out, c + 8)), (4, 2), "channel {i}"); + // Minimum and maximum data value and quantity, all four 32-bit floats. + assert_eq!((f32_at(c + 12), f32_at(c + 16), f32_at(c + 20), f32_at(c + 24)), (0.0, 0.0, 1023.0, 2.048), "channel {i}"); + } + // Pixel interleave, 32-bit words left justified, unsigned, positive, no padding. + assert_eq!(&out[680..692], &[0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + // Two lines of twelve samples: four words each. + assert_eq!(out.len(), 2048 + 2 * 16); +} + +#[test] +fn any_image_saves_as_ten_bit_cineon() { + // 16-bit samples land on the nearest 10-bit code; colour with alpha drops it, gray stays gray. + let wide = Image::from_u16(3, 1, ChannelLayout::Rgba, &[0, 32, 65535, 65535, 30000, 40000, 50000, 0, 1, 2, 3, 4]).unwrap(); + let out = encode(&wide, Format::Cineon, &EncodeOptions::default()).unwrap(); + let to_code = |v: u16| (f64::from(v) * 1023.0 / 65535.0).round() as u16; + let want: Vec = [0u16, 32, 65535, 30000, 40000, 50000, 1, 2, 3].into_iter().map(to_code).collect(); + assert_eq!(cineon_written(&out), (3, 3, 1, want)); + assert!(fidelity_warnings(&wide, Format::Cineon).contains(&FidelityWarning::AlphaDiscarded)); + let gray8 = Image::from_u8(2, 1, ChannelLayout::Gray, vec![0, 255]).unwrap(); + assert_eq!(cineon_written(&encode(&gray8, Format::Cineon, &EncodeOptions::default()).unwrap()), (1, 2, 1, vec![0, 1023])); +} diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/it/exr_cryptomatte.rs similarity index 100% rename from crates/codecs/tests/exr_cryptomatte.rs rename to crates/codecs/tests/it/exr_cryptomatte.rs diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/it/exr_multipart.rs similarity index 65% rename from crates/codecs/tests/exr_multipart.rs rename to crates/codecs/tests/it/exr_multipart.rs index f106d37791..421d494710 100644 --- a/crates/codecs/tests/exr_multipart.rs +++ b/crates/codecs/tests/it/exr_multipart.rs @@ -8,7 +8,9 @@ use exr::prelude::{ WritableImage, }; use photocraft_codecs::f16; -use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info}; +use photocraft_codecs::{ + ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size, +}; fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec)]) -> Layer> { let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len()); @@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() { assert_eq!((img.width(), img.height()), (4, 4)); assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels"); } + +fn channel_group_file(channels: &[(&str, f32)]) -> Vec { + let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect(); + write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)]) +} + +fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) { + assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8); + assert_eq!(probe_size(bytes).unwrap(), (2, 2)); + for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] { + assert_eq!(img.layout(), layout); + assert_eq!(img.to_normalized(), expected.repeat(4)); + } +} + +#[test] +fn flat_decode_keeps_rgb_channels_in_one_group() { + // A partial group sorts before the nested beauty layer, but cannot donate its R. + let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_does_not_borrow_another_groups_alpha() { + for alpha in ["A", "aaa.A", "beauty.other.A"] { + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]); + assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]); + } +} + +#[test] +fn flat_decode_rejects_rgb_split_between_groups() { + for channels in [ + [("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)], + [("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)], + [(".R", 0.25), ("G", 0.5), ("B", 2.0)], + ] { + let bytes = channel_group_file(&channels); + assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0); + assert!(probe_size(&bytes).is_err()); + assert!(decode(&bytes).is_err()); + assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err()); + } +} + +#[test] +fn auto_pick_and_probe_skip_incoherent_rgb_parts() { + let bytes = write_image(vec![ + layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]), + layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]), + ]); + let parts = exr_info(&bytes, &Limits::default()).unwrap(); + assert_eq!(parts.iter().map(|part| part.color_rank()).collect::>(), [0, 2]); + assert_eq!(probe_size(&bytes).unwrap(), (4, 4)); + let img = decode(&bytes).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA)); + assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16)); +} + +#[test] +fn flat_decode_prefers_the_highest_ranked_complete_group() { + let bytes = + channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]); +} + +#[test] +fn flat_decode_breaks_group_ties_by_prefix() { + let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + // Even a prefix that sorts before the unprefixed channel names loses a rank tie. + let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() { + for prefix in ["", "beauty."] { + let y = format!("{prefix}Y"); + let a = format!("{prefix}A"); + assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]); + assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]); + } + for name in ["depth.Z", "A", "beauty.A"] { + // A lone alpha is one grayscale channel, not the same samples duplicated as YA. + assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]); + } +} + +#[test] +fn half_float_group_does_not_use_unrelated_float_channels() { + let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]); + for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] { + layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4]))); + } + layer.channel_data = AnyChannels::sort(layer.channel_data.list); + let bytes = write_image(vec![layer]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16); +} diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/it/fidelity.rs similarity index 99% rename from crates/codecs/tests/fidelity.rs rename to crates/codecs/tests/it/fidelity.rs index 93b0236fdf..2c584a559a 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/it/fidelity.rs @@ -1,6 +1,6 @@ //! `fidelity_warnings` cases, and consistency with what `encode` does. -mod common; +use crate::common; use FidelityWarning as W; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/gif_frames.rs b/crates/codecs/tests/it/gif_frames.rs similarity index 100% rename from crates/codecs/tests/gif_frames.rs rename to crates/codecs/tests/it/gif_frames.rs diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/it/heif.rs similarity index 99% rename from crates/codecs/tests/heif.rs rename to crates/codecs/tests/it/heif.rs index 6471a5ece6..bc1a185ae1 100644 --- a/crates/codecs/tests/heif.rs +++ b/crates/codecs/tests/it/heif.rs @@ -8,7 +8,7 @@ #![cfg(all(feature = "corpus", feature = "heif"))] -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use std::sync::LazyLock; diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/it/limits.rs similarity index 99% rename from crates/codecs/tests/limits.rs rename to crates/codecs/tests/it/limits.rs index 6969fea922..c1873771f6 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/it/limits.rs @@ -1,6 +1,6 @@ //! Decompression-bomb guards. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 0000000000..3e77f071f0 --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,35 @@ +//! The crate's integration tests, linked into one test binary. + +mod camera_raw; +mod common; +mod dds; +mod dds_write; +mod decode_warnings; +mod deep_exr; +mod detect_caps; +mod dpx; +mod exr_cryptomatte; +mod exr_multipart; +mod fidelity; +mod gif_frames; +mod heif; +mod limits; +mod malformed; +mod metadata; +mod orientation; +mod pcx; +mod png_jpeg; +mod pnm_headers; +mod resolution; +mod roundtrip; +mod source_settings; +mod tga; +mod tiff_bench; +mod tiff_exif; +mod tiff_lab; +mod tiff_pages; +mod tiff_photoshop; +mod tiff_regressions; +mod webp_frames; +mod webp_lossy; +mod xpm; diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/it/malformed.rs similarity index 99% rename from crates/codecs/tests/malformed.rs rename to crates/codecs/tests/it/malformed.rs index b6da84b072..4bb9ee7cb1 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/it/malformed.rs @@ -1,6 +1,6 @@ //! Malformed input must produce errors, never panics or huge allocations. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use proptest::prelude::*; diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/it/metadata.rs similarity index 99% rename from crates/codecs/tests/metadata.rs rename to crates/codecs/tests/it/metadata.rs index d8f4d80a9a..b33ae0a715 100644 --- a/crates/codecs/tests/metadata.rs +++ b/crates/codecs/tests/it/metadata.rs @@ -1,6 +1,6 @@ //! ICC / EXIF / XMP / DPI / text preservation, per format, per caps. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; @@ -36,7 +36,7 @@ fn check_format(format: Format) { } if c.exif && format == Format::Tiff { // TIFF writes the block's directories as its own and rebuilds a block from them on - // reading (tests/tiff_exif.rs); IFD0's orientation here is the image's. + // reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's. assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); } else if c.exif { assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/it/orientation.rs similarity index 99% rename from crates/codecs/tests/orientation.rs rename to crates/codecs/tests/it/orientation.rs index c1f176b87c..26eeae030c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/it/orientation.rs @@ -1,7 +1,7 @@ //! EXIF / TIFF orientation (#285): files are opened upright, and every export //! writes Orientation = 1 so the upright pixels are never rotated twice. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest}; @@ -372,7 +372,7 @@ fn every_layout_and_depth_orients_and_inverts() { } /// #285 performance budget: turning a 24 MP photo upright on open adds ≤ 50 ms -/// (release build: `cargo test --release -p photocraft-codecs --test orientation -- --ignored`). +/// (release build: `cargo test --release -p photocraft-codecs --test it -- orientation:: --ignored`). #[test] #[ignore = "timing; run in release"] fn rotating_24_mp_is_fast() { diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/it/pcx.rs similarity index 84% rename from crates/codecs/tests/pcx.rs rename to crates/codecs/tests/it/pcx.rs index 89661cb8eb..a5796473e6 100644 --- a/crates/codecs/tests/pcx.rs +++ b/crates/codecs/tests/it/pcx.rs @@ -79,7 +79,7 @@ fn decodes_4bit_and_monochrome_palettes() { fn rejects_truncated_and_overshooting_rle_without_panicking() { let mut b = header(8, 3, 2, 1, 2); assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated"); - b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row + b.extend([0xc7, 0xff]); // a seven-byte run cannot cross a six-byte scanline assert!(decode_as(Format::Pcx, &b).is_err()); let mut b = header(8, 3, 2, 1, 2); b.extend([0xc1]); // missing run value @@ -98,3 +98,21 @@ fn refuses_headers_that_exceed_allocation_budget() { b[2] = 0; assert_eq!(detect(&b), None, "unsupported uncompressed encoding"); } + +#[test] +fn decodes_rgb_runs_across_planes() { + let mut b = header(8, 3, 2, 1, 2); + b.extend([0xc4, 0xff, 0xc2, 0]); + let im = decode(&b).unwrap(); + assert_eq!(im.data(), &[255, 255, 0, 255, 255, 0]); + let png = encode(&im, Format::Png, &Default::default()).unwrap(); + assert_eq!(decode(&png).unwrap().data(), im.data()); +} + +#[test] +fn decodes_four_plane_runs_across_planes() { + let mut b = header(1, 4, 2, 1, 2); + b[16 + 15 * 3..16 + 16 * 3].copy_from_slice(&[255, 255, 0]); + b.extend([0xc8, 0xff]); + assert_eq!(decode(&b).unwrap().data(), &[255, 255, 0, 255, 255, 0]); +} diff --git a/crates/codecs/tests/png_jpeg.rs b/crates/codecs/tests/it/png_jpeg.rs similarity index 99% rename from crates/codecs/tests/png_jpeg.rs rename to crates/codecs/tests/it/png_jpeg.rs index bd704e9a1a..0b8b42a7b7 100644 --- a/crates/codecs/tests/png_jpeg.rs +++ b/crates/codecs/tests/it/png_jpeg.rs @@ -1,7 +1,7 @@ //! Format-specific behaviour: Adam7 PNG, JPEG colour models, oracle //! cross-checks against the `image` crate, and the optional PngSuite corpus. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/pnm_headers.rs b/crates/codecs/tests/it/pnm_headers.rs similarity index 100% rename from crates/codecs/tests/pnm_headers.rs rename to crates/codecs/tests/it/pnm_headers.rs diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/it/resolution.rs similarity index 96% rename from crates/codecs/tests/resolution.rs rename to crates/codecs/tests/it/resolution.rs index 15ddc95749..28c8203c96 100644 --- a/crates/codecs/tests/resolution.rs +++ b/crates/codecs/tests/it/resolution.rs @@ -284,6 +284,17 @@ fn truncated_metadata_never_panics_and_falls_back() { assert_eq!(xmp_resolution(x), None, "{x}"); let _ = xmp_with_resolution(x, (1.0, 1.0)); } + + // Property-like text repeated inside the packet must not trigger a full-suffix rescan + // per occurrence (it used to be quadratic). With the cursor advancing past each located + // value, this completes well under a second even with many decoys; a timeout here would + // indicate the quadratic scan returned. A clean packet still extracts correctly. + let start = std::time::Instant::now(); + let decoys = " tiff:ResolutionUnit>".repeat(50_000); + let _ = xmp_resolution(&decoys); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "xmp scan took too long: {:?}", start.elapsed()); + let clean = "300/1300/12"; + near(xmp_resolution(clean), 300.0); } proptest! { diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs similarity index 81% rename from crates/codecs/tests/roundtrip.rs rename to crates/codecs/tests/it/roundtrip.rs index 7918d313b6..acbd16c5db 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/it/roundtrip.rs @@ -2,7 +2,7 @@ //! Lossless formats must reproduce the (planned) conversion of the input //! exactly; lossy formats must stay above a PSNR threshold. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; @@ -137,6 +137,9 @@ mod fits { mod dpx { cases!(Format::Dpx); } +mod dds { + cases!(Format::Dds); +} mod qoi { cases!(Format::Qoi); } @@ -239,6 +242,48 @@ fn jpeg_444_beats_420_on_chroma_detail() { assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap())); } +/// JPEG Options › Format Options: each scan layout decodes back to the same image, Progressive +/// writes a progressive frame (SOF2), and Baseline Optimized fits its Huffman tables (smaller at +/// 4:4:4; at 4:2:0 it keeps the standard tables, see `JpegFormat::Optimized`). +#[test] +fn jpeg_format_options_round_trip() { + let img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.5); + let sof = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for subsampled in [false, true] { + let enc = |f| { + encode(&img, Format::Jpeg, &EncodeOptions { jpeg_format: f, jpeg_chroma_subsampling: subsampled, jpeg_quality: 90, ..Default::default() }).unwrap() + }; + let (base, opt, prog) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized), enc(JpegFormat::Progressive)); + // The scan layout only changes how the same quantized coefficients are stored, so every + // format decodes as faithfully as Baseline. + let reference = psnr(&img, &decode(&base).unwrap()); + for (name, b) in [("optimized", &opt), ("progressive", &prog)] { + let back = decode(b).unwrap(); + assert_eq!(back.dimensions(), img.dimensions(), "{name}"); + let p = psnr(&img, &back); + assert!((p - reference).abs() < 0.5, "{name}, subsampled {subsampled}: {p} vs baseline {reference}"); + } + assert!(sof(&base, 0xC0) && !sof(&base, 0xC2)); + assert!(sof(&prog, 0xC2), "progressive frame"); + if subsampled { + assert_eq!(opt, base, "4:2:0 keeps the standard tables"); + } else { + assert!(opt.len() < base.len(), "fitted tables are smaller: {} vs {}", opt.len(), base.len()); + } + } + // Gray is 4:4:4 too, so Baseline Optimized shrinks it. + let gray = synth(64, 48, ChannelLayout::Gray, SampleType::U8, 3, 0.5); + let enc = |f| encode(&gray, Format::Jpeg, &EncodeOptions { jpeg_format: f, ..Default::default() }).unwrap(); + let (base, opt) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized)); + assert!(opt.len() < base.len(), "gray: {} vs {}", opt.len(), base.len()); + assert!((psnr(&gray, &decode(&opt).unwrap()) - psnr(&gray, &decode(&base).unwrap())).abs() < 0.5); + assert_eq!(JpegFormat::parse("progressive"), Some(JpegFormat::Progressive)); + assert_eq!(JpegFormat::parse(JpegFormat::Optimized.name()), Some(JpegFormat::Optimized)); + for bad in ["Progressive", "", "standard"] { + assert_eq!(JpegFormat::parse(bad), None, "{bad}"); + } +} + #[test] fn webp_lossy_request_is_a_lossy_file() { let img = test_image(ChannelLayout::Rgb, SampleType::U8); diff --git a/crates/codecs/tests/source_settings.rs b/crates/codecs/tests/it/source_settings.rs similarity index 99% rename from crates/codecs/tests/source_settings.rs rename to crates/codecs/tests/it/source_settings.rs index 9da82dbe79..0389e2fa8e 100644 --- a/crates/codecs/tests/source_settings.rs +++ b/crates/codecs/tests/it/source_settings.rs @@ -1,7 +1,7 @@ //! Reading back the settings a lossy file was saved with: a JPEG's quality from its //! quantisation tables, a WebP's lossy or lossless encoding (and its quality). -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/it/tga.rs similarity index 100% rename from crates/codecs/tests/tga.rs rename to crates/codecs/tests/it/tga.rs diff --git a/crates/codecs/tests/tiff_bench.rs b/crates/codecs/tests/it/tiff_bench.rs similarity index 97% rename from crates/codecs/tests/tiff_bench.rs rename to crates/codecs/tests/it/tiff_bench.rs index 357c10486e..e17f6466d4 100644 --- a/crates/codecs/tests/tiff_bench.rs +++ b/crates/codecs/tests/it/tiff_bench.rs @@ -2,10 +2,10 @@ //! release: //! //! ```sh -//! cargo test --release -p photocraft-codecs --test tiff_bench -- --ignored --nocapture +//! cargo test --release -p photocraft-codecs --test it -- tiff_bench:: --ignored --nocapture //! ``` -mod common; +use crate::common; use common::tiffgen::*; use photocraft_codecs::*; use std::time::Instant; diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/it/tiff_exif.rs similarity index 61% rename from crates/codecs/tests/tiff_exif.rs rename to crates/codecs/tests/it/tiff_exif.rs index 92e8c91df0..7e06a0e5b8 100644 --- a/crates/codecs/tests/tiff_exif.rs +++ b/crates/codecs/tests/it/tiff_exif.rs @@ -166,3 +166,97 @@ fn no_exif_is_written_without_embed_metadata() { let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap(); assert_eq!(decode(&tiff).unwrap().meta.exif, None); } + +// --------------------------------------------------------------------------- +// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have +// many entries point at the same bytes. +// --------------------------------------------------------------------------- + +/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes +/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to +/// copy `entries * count`. +fn overlapping_tiff(entries: usize, count: u32) -> Vec { + let entry = |tag: u16, ty: u16, count: u32, value: u32| { + [tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat() + }; + let at_exif = 8 + 2 + 12 * 10 + 4; + let data_at = at_exif + 2 + 12 * entries + 4; + let mut out = b"II".to_vec(); + out.extend(42u16.to_le_bytes()); + out.extend(8u32.to_le_bytes()); + out.extend(10u16.to_le_bytes()); + for (tag, ty, value) in + [(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)] + { + out.extend(entry(tag, ty, 1, value)); + } + out.extend(0u32.to_le_bytes()); + out.extend((entries as u16).to_le_bytes()); + for i in 0..entries { + out.extend(entry(i as u16 + 1, 7, count, data_at as u32)); + } + out.extend(0u32.to_le_bytes()); + out.extend(vec![0x5A; count as usize]); + out +} + +#[test] +fn overlapping_field_values_are_bounded() { + // About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes. + let tiff = overlapping_tiff(1000, 50_000); + assert!(tiff.len() < 64 * 1024); + let img = decode(&tiff).expect("the image itself is fine"); + assert_eq!(img.dimensions(), (1, 1)); + let exif = img.meta.exif.expect("the first entries are kept"); + assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len()); +} + +#[test] +fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() { + // The same block as an image's EXIF: the writer reads it with the same code. + let block = overlapping_tiff(1000, 50_000); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len()); +} + +/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference. +fn block_with_maker_note(le: bool, len: usize) -> Vec { + let b = Block { le }; + b.build( + vec![(271, 2, 4, b"Cam\0".to_vec())], + vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())], + vec![(1, 2, 2, b"N\0".to_vec())], + ) +} + +#[test] +fn large_maker_notes_still_survive() { + // Nikon maker notes run from 100 KB to nearly 1 MB. + for len in [200 * 1024, 1000 * 1024] { + let block = block_with_maker_note(true, len); + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (ble, _, bexif, bgps) = dirs(&back); + let (ole, _, oexif, ogps) = dirs(&block); + assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note"); + assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole)); + } +} + +#[test] +fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() { + let block = block_with_maker_note(false, 6 * 1024 * 1024); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len()); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (_, ifd0, exif, gps) = dirs(&back); + assert_eq!(exif.iter().map(|e| e.0).collect::>(), [33434], "the exposure time stays, the 6 MiB maker note goes"); + assert_eq!(gps.iter().map(|e| e.0).collect::>(), [1]); + assert_eq!(ifd0.iter().map(|e| e.0).collect::>(), [271]); +} diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/it/tiff_lab.rs similarity index 100% rename from crates/codecs/tests/tiff_lab.rs rename to crates/codecs/tests/it/tiff_lab.rs diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/it/tiff_pages.rs similarity index 99% rename from crates/codecs/tests/tiff_pages.rs rename to crates/codecs/tests/it/tiff_pages.rs index 60885e527d..db432abe51 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/it/tiff_pages.rs @@ -4,7 +4,7 @@ //! offsets past the end, absurd sizes) that must give `Ok` or a clean `Err`, never a panic. //! Every file here is hand-built by `common::tiffgen`. -mod common; +use crate::common; use common::Rng; use common::tiffgen::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/it/tiff_photoshop.rs similarity index 99% rename from crates/codecs/tests/tiff_photoshop.rs rename to crates/codecs/tests/it/tiff_photoshop.rs index 400b72eb0c..238710b68c 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/it/tiff_photoshop.rs @@ -2,7 +2,7 @@ //! `Metadata` as opaque bytes: written when present, read from either byte order and from //! BigTIFF, and never a panic or an allocation on malformed input. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tiff_regressions.rs b/crates/codecs/tests/it/tiff_regressions.rs similarity index 99% rename from crates/codecs/tests/tiff_regressions.rs rename to crates/codecs/tests/it/tiff_regressions.rs index d823f33f4a..8fcb1e570c 100644 --- a/crates/codecs/tests/tiff_regressions.rs +++ b/crates/codecs/tests/it/tiff_regressions.rs @@ -1,4 +1,4 @@ -mod common; +use crate::common; use common::tiffgen::*; use photocraft_codecs::{Image, decode, exif_orientation, upright_exif}; diff --git a/crates/codecs/tests/it/webp_frames.rs b/crates/codecs/tests/it/webp_frames.rs new file mode 100644 index 0000000000..5045633cad --- /dev/null +++ b/crates/codecs/tests/it/webp_frames.rs @@ -0,0 +1,166 @@ +//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198). + +use photocraft_codecs::{ + AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode, + webp_is_animated, +}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +fn frames(bytes: &[u8]) -> Vec { + decode_webp_frames(bytes, &DecodeOptions::default()).unwrap() +} + +fn solid(c: [u8; 4], w: u32, h: u32) -> Image { + Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap() +} + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c +} + +fn u24(v: u32) -> [u8; 3] { + let b = v.to_le_bytes(); + [b[0], b[1], b[2]] +} + +/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP +/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the +/// canvas rather than replacing it. +struct Part { + image: Image, + at: [u32; 2], + duration_ms: u32, + blend: bool, +} + +fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part { + Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false } +} + +/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part, +/// each holding a lossless still from our own encoder. +fn animated(w: u32, h: u32, parts: &[Part]) -> Vec { + let mut body = b"WEBP".to_vec(); + let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags + vp8x.extend_from_slice(&u24(w - 1)); + vp8x.extend_from_slice(&u24(h - 1)); + body.extend(chunk(b"VP8X", &vp8x)); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever + for p in parts { + let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap(); + let at = still.windows(4).position(|w| w == b"VP8L").unwrap(); + let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize; + let mut anmf = Vec::new(); + anmf.extend_from_slice(&u24(p.at[0] / 2)); + anmf.extend_from_slice(&u24(p.at[1] / 2)); + anmf.extend_from_slice(&u24(p.image.width() - 1)); + anmf.extend_from_slice(&u24(p.image.height() - 1)); + anmf.extend_from_slice(&u24(p.duration_ms)); + anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend + anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len])); + body.extend(chunk(b"ANMF", &anmf)); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend(body); + out +} + +fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] { + let rgba = f.image.to_rgba8(); + let i = ((y * f.image.width() + x) * 4) as usize; + rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_still_webp_gives_one_frame_equal_to_decode() { + let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap(); + assert!(!webp_is_animated(&still)); + let f = frames(&still); + assert_eq!(f.len(), 1); + assert_eq!(f[0].delay_ms, 0); + assert_eq!(f[0].image, decode(&still).unwrap()); +} + +#[test] +fn a_one_frame_animation_is_not_animated() { + let a = animated(6, 4, &[part(RED, 6, 4, 70)]); + assert!(!webp_is_animated(&a)); + assert_eq!(frames(&a).len(), 1); +} + +#[test] +fn every_frame_comes_back_in_order_with_its_duration() { + let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]); + assert!(webp_is_animated(&a)); + let f = frames(&a); + assert_eq!(f.iter().map(|f| f.delay_ms).collect::>(), [40, 120, 7], "WebP durations are milliseconds"); + for (f, c) in f.iter().zip([RED, GREEN, BLUE]) { + assert_eq!((f.image.width(), f.image.height()), (6, 4)); + assert_eq!(pixel(f, 0, 0), c); + assert_eq!(pixel(f, 5, 3), c); + assert_eq!(f.image.warnings, []); + } +} + +#[test] +fn a_smaller_frame_is_drawn_over_the_previous_one() { + let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true }; + let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small])); + assert_eq!(f.len(), 2); + // Alpha blending rounds: within one level. + let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through"); + assert!(near(pixel(&f[1], 2, 2), BLUE)); + assert!(near(pixel(&f[1], 3, 3), BLUE)); + assert!(near(pixel(&f[1], 4, 2), RED)); +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]); + // Break the last frame's VP8L signature byte; the container around it stays whole. + let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap(); + a[last + 8] = 0; + let f = frames(&a); + assert_eq!(f.len(), 2); + assert_eq!(pixel(&f[0], 0, 0), RED); + assert_eq!(pixel(&f[1], 0, 0), GREEN); + assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]); +} + +#[test] +fn every_prefix_of_an_animated_webp_returns() { + let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]); + for n in 0..a.len() { + let _ = decode_webp_frames(&a[..n], &DecodeOptions::default()); + } +} + +#[test] +fn dimension_limits_apply() { + let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() }; + assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn the_byte_budget_covers_all_frames_together() { + // One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so + // two frames need more than 128; ten frames of the same size must not fit where two do. + let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let ten: Vec = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect(); + let ten = animated(4, 4, &ten); + let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() }; + assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2); + assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_)))); +} diff --git a/crates/codecs/tests/webp_lossy.rs b/crates/codecs/tests/it/webp_lossy.rs similarity index 99% rename from crates/codecs/tests/webp_lossy.rs rename to crates/codecs/tests/it/webp_lossy.rs index f0ff2cbbb4..079b9134fd 100644 --- a/crates/codecs/tests/webp_lossy.rs +++ b/crates/codecs/tests/it/webp_lossy.rs @@ -3,7 +3,7 @@ //! smaller than lossless on photographic content; alpha and metadata travel in the container; //! odd sizes and the limits behave. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/it/xpm.rs similarity index 100% rename from crates/codecs/tests/xpm.rs rename to crates/codecs/tests/it/xpm.rs diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index cd8f6eac36..5cf5fbd691 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -460,6 +460,22 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: out } +/// [`composite`] with the backdrop's alpha kept (Lock Transparent Pixels, Fill's Preserve +/// Transparency): "source atop", `Co = (1−αs)·Cb + αs·B(Cb,Cs)`, `αo = αb`. Unlike source-over +/// followed by restoring the alpha, a half-transparent backdrop takes the new colour by `αs` alone. +pub fn composite_atop(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let b = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]); + let mut out = backdrop; + for i in 0..3 { + out[i] = (1.0 - as_) * backdrop[i] + as_ * b[i]; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -816,4 +832,20 @@ mod tests { let r = composite(BlendMode::Screen, [0.0, 0.0, 0.0, 0.5], [0.0, 0.0, 0.0, 0.5], 1.0); assert!(close(r[3], 0.75)); } + + #[test] + fn composite_atop_keeps_alpha_and_moves_colour_by_source_alpha() { + let bg = [1.0, 1.0, 1.0, 0.5]; + // Half-transparent white, black at 50%: halfway, alpha kept (#5783). + let r = composite_atop(BlendMode::Normal, bg, [0.0, 0.0, 0.0, 1.0], 0.5); + assert!(close(r[0], 0.5) && close(r[2], 0.5) && r[3] == 0.5, "{r:?}"); + // Non-Normal modes blend against the backdrop as it is: Multiply by black at 50% halves it. + let r = composite_atop(BlendMode::Multiply, bg, [0.0, 0.0, 0.0, 1.0], 0.5); + assert!(close(r[0], 0.5) && r[3] == 0.5, "{r:?}"); + // Opaque backdrops agree with source-over. + let opaque = [0.2, 0.4, 0.6, 1.0]; + assert_eq!(composite_atop(BlendMode::Screen, opaque, [0.5, 0.5, 0.5, 0.7], 0.8), composite(BlendMode::Screen, opaque, [0.5, 0.5, 0.5, 0.7], 0.8)); + // A transparent source leaves the backdrop. + assert_eq!(composite_atop(BlendMode::Normal, bg, [0.0, 0.0, 0.0, 0.0], 1.0), bg); + } } diff --git a/crates/color/src/convert.rs b/crates/color/src/convert.rs index ed7cb60e8e..6092dc3979 100644 --- a/crates/color/src/convert.rs +++ b/crates/color/src/convert.rs @@ -213,6 +213,48 @@ pub fn rgb_to_gray(rgb: [f32; 3]) -> f32 { 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] } +/// HSB of an sRGB colour with the hue in sextants (0..6) instead of degrees, and hue 0 for greys. +pub fn rgb_to_hsb_sextant(rgb: [f32; 3]) -> [f32; 3] { + let (mx, mn) = (rgb[0].max(rgb[1]).max(rgb[2]), rgb[0].min(rgb[1]).min(rgb[2])); + let d = mx - mn; + let h = if d <= 0.0 { + 0.0 + } else if mx == rgb[0] { + ((rgb[1] - rgb[2]) / d).rem_euclid(6.0) + } else if mx == rgb[1] { + (rgb[2] - rgb[0]) / d + 2.0 + } else { + (rgb[0] - rgb[1]) / d + 4.0 + }; + [h, if mx > 0.0 { d / mx } else { 0.0 }, mx] +} + +/// The sRGB colour at a hue sextant (0..6) with the given chroma, raised by offset on every channel. +pub fn rgb_from_hue_sextant(sextant: f32, chroma: f32, offset: f32) -> [f32; 3] { + let x = chroma * (1.0 - (sextant.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match sextant as u32 { + 0 => (chroma, x, 0.0), + 1 => (x, chroma, 0.0), + 2 => (0.0, chroma, x), + 3 => (0.0, x, chroma), + 4 => (x, 0.0, chroma), + _ => (chroma, 0.0, x), + }; + [r + offset, g + offset, b + offset] +} + +/// HSB to sRGB, the hue in degrees (wrapping) and saturation and brightness in 0..1, as Photoshop's HSB. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let chroma = v * s; + rgb_from_hue_sextant(h.rem_euclid(360.0) / 60.0, chroma, v - chroma) +} + +/// sRGB to HSB, the hue in degrees 0..360 and saturation and brightness in 0..1. +pub fn rgb_to_hsb(rgb: [f32; 3]) -> [f32; 3] { + let [h, s, v] = rgb_to_hsb_sextant(rgb); + [60.0 * h, s, v] +} + #[cfg(test)] mod tests { use super::*; @@ -294,6 +336,7 @@ mod tests { }); } + /// Lab round-trips sRGB and puts white at L 100 and black at L 0. #[test] fn lab_roundtrip_and_anchors() { let white = srgb_to_lab([1.0, 1.0, 1.0]); @@ -307,4 +350,22 @@ mod tests { } } } + + /// HSB hits the primary and grey anchors and round-trips sRGB. + #[test] + fn hsb_conversions_match_known_colours_and_round_trip() { + assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]); + assert_eq!(hsb_to_rgb([0.0, 0.0, 0.5]), [0.5, 0.5, 0.5]); + assert_eq!(rgb_to_hsb([0.0, 0.0, 1.0]), [240.0, 1.0, 1.0]); + assert_eq!(rgb_to_hsb([0.25, 0.25, 0.25]), [0.0, 0.0, 0.25]); + for r in 0..=8 { + for g in 0..=8 { + for b in 0..=8 { + let c = [r as f32 / 8.0, g as f32 / 8.0, b as f32 / 8.0]; + let back = hsb_to_rgb(rgb_to_hsb(c)); + assert!(back.iter().zip(c).all(|(x, y)| (x - y).abs() < 1e-5), "{c:?} -> {back:?}"); + } + } + } + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index a33437963b..281c3e01b9 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -120,6 +120,29 @@ pub fn read_sample(bytes: &[u8], sample: SampleType, index: usize) -> f32 { } } +/// [`read_sample`] for the `out.len()` samples from sample index `start` on, matching the sample +/// type once instead of per sample. +pub fn read_samples(bytes: &[u8], sample: SampleType, start: usize, out: &mut [f32]) { + let end = start + out.len(); + match sample { + SampleType::U8 => { + for (o, b) in out.iter_mut().zip(&bytes[start..end]) { + *o = *b as f32 / 255.0; + } + } + SampleType::U16 => { + for (o, b) in out.iter_mut().zip(bytes[start * 2..end * 2].as_chunks::<2>().0) { + *o = u16::from_ne_bytes(*b) as f32 / 65535.0; + } + } + SampleType::F32 => { + for (o, b) in out.iter_mut().zip(bytes[start * 4..end * 4].as_chunks::<4>().0) { + *o = f32::from_ne_bytes(*b); + } + } + } +} + /// Write a normalised f32 sample; integer types are clamped and rounded. #[inline] pub fn write_sample(bytes: &mut [u8], sample: SampleType, index: usize, v: f32) { diff --git a/crates/compose/examples/bench_tsly_stroke.rs b/crates/compose/examples/bench_tsly_stroke.rs index 97ff9c85b6..ec5a91b19f 100644 --- a/crates/compose/examples/bench_tsly_stroke.rs +++ b/crates/compose/examples/bench_tsly_stroke.rs @@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result> size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)), + overprint: false, })); layer.advanced.transparency_shapes = false; document.layers.push(layer); diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index f91e7779d1..d122a09993 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -138,12 +138,12 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } } Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => { - let luts = tone_luts_depth(adj, depth); let identity = float_tone_identity(adj, depth); - match space { - ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })), - ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)), + if identity == [true; 3] { + return; } + let luts = tone_luts_depth(adj, depth); + apply_tone(buf, *space, &luts, identity); } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges)); @@ -158,7 +158,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } None => (0.0, 0.0, 0.0), }; - let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize); + let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth); + let out = hue_saturation_in(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize, depth); range_lightness(out, dl) }) } @@ -232,7 +233,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { - map_rgb(buf, |c| std::array::from_fn(|ch| color_balance_channel([*shadows, *midtones, *highlights], *preserve_luminosity, ch, c[ch]))) + let curves = BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + map_rgb(buf, |c| std::array::from_fn(|ch| curves[ch].eval(c[ch]))) } Adjustment::SelectiveColor { relative, adjustments } => map_rgb(buf, |c| selective_color(c, *relative, adjustments)), Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => { @@ -572,6 +574,13 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool } } +pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec; 4], identity: [bool; 3]) { + match space { + ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })), + ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)), + } +} + /// Per-channel LUTs of a Levels or Curves adjustment: each channel's record first, then the /// master (composite) record, as Photoshop does (psd-tools levels_rgb.psd and curves_rgb.psd, /// #975). Four rows: the three `per_channel` channels then black @@ -657,10 +666,31 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec; 3] { std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect()) } +/// Hue/Saturation's saturation `s` (-1..1) as [`hue_saturation`] takes it in a document of +/// `depth`. A 32-bit Colorize builds its colour with the saturation times `1 - |lightness|`: +/// the photoshop corpus's rgb32 hue-saturation-colorize.psd (Saturation 40, Lightness +5) has 0.95 +/// of the chroma at every lightness, and is exact so, where 8 and 16 bits keep the full saturation +/// (their oracles are exact without it). Only a raised Lightness is measured; a lowered one is +/// taken to scale the same way. +pub fn colorize_saturation(s: f32, lightness: f32, colorize: bool, depth: Option) -> f32 { + // Clamped here too: a PSD can store a saturation beyond ±100, and the GPU scales it unclamped. + if colorize && depth == Some(SampleType::F32) { s.clamp(-1.0, 1.0) * (1.0 - lightness.abs().min(1.0)) } else { s } +} + /// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1. /// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the /// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%. pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] { + hue_saturation_in(c, hue, s, l, colorize, None) +} + +/// [`hue_saturation`] in a document of `depth`. At 32 bits Photoshop doesn't clip the raised +/// saturation, so saturated colours leave 0..1 (red with Hue +40, Saturation +25, Lightness -10 +/// becomes (1.0477, 0.649, -0.1477)), and it scales each colour's chroma around its lightness by +/// [`hdr_saturation_factor`], so colours at or above lightness 1 keep their hue too: the photoshop +/// corpus's rgb32 hue-saturation.psd is exact so. +pub fn hue_saturation_in(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool, depth: Option) -> [f32; 3] { + let hdr = depth == Some(SampleType::F32); let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) }; if colorize { // Colorize builds the colour at the lightened (or darkened) lightness, so black with @@ -671,14 +701,36 @@ pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> // Photoshop applies Lightness first, then rotates the hue and scales the saturation of the // result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not // (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation). - let (hh, ss, ll) = rgb_to_hsl(c.map(lighten)); + let lc = c.map(lighten); + let (hh, ss, ll) = rgb_to_hsl(lc); let hh = (hh + hue / 360.0).rem_euclid(1.0); + if hdr { + // The chroma, not the HSL saturation, whose `2 - max - min` divisor is 0 at lightness 1 + // and negative above it: (2, 0, 0) with Hue +40 becomes (2, 1.333, 0) in Photoshop. + let d = (lc[0].max(lc[1]).max(lc[2]) - lc[0].min(lc[1]).min(lc[2])) * hdr_saturation_factor(s); + return hue_rgb(hh, ll - d / 2.0, ll + d / 2.0); + } // Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it // (measured in Photoshop 27.11); +100 % saturates any colour that has some. let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0); hsl_to_rgb(hh, ss, ll) } +/// The factor 32-bit Hue/Saturation scales chroma by for saturation `s` (-1..1), as Photoshop +/// computes it (measured at every whole percent from -100 to +100 on a 32-bit document): lowering +/// cuts `s` to 1/256 steps, raising cuts it to 254 steps out of 255, takes 255 / (255 - steps) to +/// 1/256 and stops at 32767/256, so +25 % gives 340/256, +99 % 63.75 and +100 % 127.996. +/// compose.wgsl's Hue/Saturation does the same in whole numbers. +pub fn hdr_saturation_factor(s: f32) -> f32 { + // The 1e-4 keeps a master plus range sum on its step: -0.46 + 0.96 is 0.49999997 in f32. + let s = s.clamp(-1.0, 1.0); + if s <= 0.0 { + return 1.0 - (-s * 256.0 + 1e-4).floor() / 256.0; + } + let steps = (s * 254.0 + 1e-4).floor() as u32; + (65280 / 255u32.saturating_sub(steps).max(1)).min(32767) as f32 / 256.0 +} + /// A colour range's Lightness, applied after the master edits. It moves each channel towards the /// pixel's brightest channel when raised and towards its darkest when lowered, not towards white /// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into @@ -779,48 +831,89 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f /// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white /// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones. /// -/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter +/// A gamma > 1 starts with slope 2^gamma at black (up to 18 of 255 brighter /// near black without it). When the black point reaches the white point, input /// at or above the white point is white and the rest black. pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { - let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); - let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); - let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); - let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); - let (black, white, m) = if preserve_luminosity { - (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) - } else { - ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) - }; - let (b, w) = (black / 255.0, 1.0 - white / 255.0); - let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - if v >= w { - return 1.0; - } - if v <= b { - return 0.0; - } - balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0)) + BalanceCurve::new(tones, preserve_luminosity, ch).eval(v) } -/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except -/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the -/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop, -/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). -fn balance_gamma(u: f32, gamma: f32) -> f32 { - let e = 1.0 / gamma.max(0.01); - let power = u.powf(e); - if gamma <= 1.0 { - return power; +/// One channel's Color Balance curve (see [`color_balance_channel`]), with the terms that don't +/// depend on the sample worked out once rather than per pixel. +pub struct BalanceCurve { + /// Input black and white points. + b: f32, + w: f32, + /// The power curve's exponent, 1 / gamma. + e: f32, + /// The Hermite join of a gamma > 1. + knee: Option, +} + +/// Where a gamma > 1 curve's Hermite join ends (`u0`), its starting slope 2^gamma (`s0`), and the +/// power curve's value (`p`) and slope (`d`) at `u0`. +#[derive(Clone, Copy)] +struct Knee { + u0: f32, + s0: f32, + p: f32, + d: f32, +} + +impl BalanceCurve { + /// The red, green and blue curves. + pub fn rgb(tones: [[f32; 3]; 3], preserve_luminosity: bool) -> [Self; 3] { + std::array::from_fn(|ch| Self::new(tones, preserve_luminosity, ch)) } - let s0 = 2f32.powf(gamma); - let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); - if u0.is_nan() || u0 <= 0.0 || u >= u0 { - return power; + + /// The curve of channel `ch` (0 red, 1 green, 2 blue) for the shadows, midtones and + /// highlights slider triples `tones`. + pub fn new(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize) -> Self { + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + // The midtone gamma of the channel's Levels: `u^(1/gamma)`, except that a gamma > 1 + // (which would start with infinite slope) starts at slope 2^gamma and joins the power + // curve in a cubic Hermite at twice the input where that line meets it (Photoshop, + // measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). + let gamma = 2f32.powf(m / 100.0); + let e = 1.0 / gamma.max(0.01); + let knee = if gamma > 1.0 { + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + (!u0.is_nan() && u0 > 0.0).then(|| Knee { u0, s0, p: u0.powf(e), d: e * u0.powf(e - 1.0) }) + } else { + None + }; + Self { b: black / 255.0, w: 1.0 - white / 255.0, e, knee } + } + + /// The curve at `v` (0..1). + pub fn eval(&self, v: f32) -> f32 { + let (b, w) = (self.b, self.w); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; + } + if v <= b { + return 0.0; + } + let u = (v - b) / (w - b); + match self.knee { + Some(Knee { u0, s0, p, d }) if u < u0 => { + let t = u / u0; + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) + } + _ => u.powf(self.e), + } } - let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); - let (t2, t3) = (t * t, t * t * t); - ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) } /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, @@ -891,7 +984,9 @@ fn map_rgb(buf: &mut Buffer, f: impl Fn([f32; 3]) -> [f32; 3]) { #[inline] fn lut(table: &[f32], v: f32) -> f32 { let x = v.clamp(0.0, 1.0) * (table.len() - 1) as f32; - let i = x.floor() as usize; + // Never negative (NaN casts to 0, as its floor did), so truncating is `floor`, without the + // libm call baseline x86-64 (no SSE4.1) makes for it. + let i = x as usize; let j = (i + 1).min(table.len() - 1); let f = x - i as f32; table[i] * (1.0 - f) + table[j] * f @@ -1382,7 +1477,11 @@ pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] { return [l; 3]; } let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; - let p = 2.0 * l - q; + hue_rgb(h, 2.0 * l - q, q) +} + +/// The colour of hue `h` (0..1) with its smallest channel at `p` and its largest at `q`. +fn hue_rgb(h: f32, p: f32, q: f32) -> [f32; 3] { let f = |mut t: f32| { t = t.rem_euclid(1.0); if t < 1.0 / 6.0 { @@ -1491,6 +1590,89 @@ mod tone_tests { [b.px[0][0], b.px[0][1], b.px[0][2]] } + /// Photoshop's 32-bit Hue/Saturation (photoshop corpus rgb32 hue-saturation.psd: hue +40, + /// saturation +25, lightness -10): saturated colours leave 0..1, and the saturation factor is + /// 1 / (1 - 63/255), so red becomes (1.0477, 0.649, -0.1477), where 8 bits clip to (0.9, 0.6, 0). + #[test] + fn thirty_two_bit_hue_saturation_is_unclipped() { + let adj = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }; + let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3); + for (c, want) in + [([1.0, 0.0, 0.0], [1.0477, 0.649, -0.1477]), ([0.9301, 0.6105, 0.2541], [0.7804, 0.9369, 0.1289]), ([0.0, 0.3864, 1.0], [0.1871, -0.1477, 1.0477])] + { + assert!(near(hdr(&adj, c), want), "{c:?}: {:?}", hdr(&adj, c)); + } + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16)); + assert!(b.px[0].iter().all(|v| (0.0..=1.0).contains(v)), "16 bits clip: {:?}", b.px[0]); + } + + /// Photoshop's 32-bit Colorize (photoshop corpus rgb32 hue-saturation-colorize.psd: hue -160, + /// saturation 40, lightness +5): red becomes (0.3445, 0.5852, 0.7055), 0.95 of the chroma an + /// 8-bit Colorize gives at the same lightness, (0.335, 0.5883, 0.715). + #[test] + fn thirty_two_bit_colorize_scales_the_saturation() { + let adj = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; + let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 2e-3); + let red = hdr(&adj, [1.0, 0.0, 0.0]); + assert!(near(red, [0.3445, 0.5852, 0.7055]), "{red:?}"); + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + apply_depth(&adj, &mut b, Transfer::Srgb, Some(SampleType::U16)); + assert!(near([b.px[0][0], b.px[0][1], b.px[0][2]], [0.335, 0.5883, 0.715]), "16-bit keeps the full saturation: {:?}", b.px[0]); + } + + /// Photoshop's 32-bit saturation factor (Hue/Saturation on red (1, 0, 0) in a 32-bit document, + /// read back from the saved float TIFF, exact in 1/512): red's R is 0.5 + 0.5 x the factor. + #[test] + fn thirty_two_bit_saturation_steps_match_photoshop() { + for (sat, want) in [ + (10.0, 539.0 / 512.0), + (75.0, 2.4609375), + (90.0, 2673.0 / 512.0), + (99.0, 32.375), + (100.0, 33023.0 / 512.0), + (-10.0, 487.0 / 512.0), + (-25.0, 0.875), + (-50.0, 0.75), + (-100.0, 0.5), + ] { + let adj = Adjustment::HueSaturation { hue: 0.0, saturation: sat, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + let got = hdr(&adj, [1.0, 0.0, 0.0])[0]; + assert!((got - want).abs() < 1e-4, "saturation {sat}: {got}, Photoshop {want}"); + } + } + + /// 32-bit colours at or above HSL lightness 1 (red at +1 stop, (2, 0, 0)) keep their hue and + /// get their chroma scaled like any other, as in Photoshop; they used to turn NaN or grey. + #[test] + fn thirty_two_bit_hue_saturation_keeps_bright_colours() { + let pale = [280.0 / 255.0, 230.0 / 255.0, 256.0 / 255.0]; + let near = |got: [f32; 3], want: [f32; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-3 * w.abs().max(1.0)); + for (hue, sat, c, want) in [ + (40.0, 0.0, [2.0, 0.0, 0.0], [2.0, 1.33301, 0.0]), + (40.0, 0.0, pale, [1.09804, 0.930687, 0.901961]), + (0.0, 25.0, [2.0, 0.0, 0.0], [2.32812, -0.328125, -0.328125]), + (0.0, 25.0, pale, [1.13021, 0.869792, 1.00521]), + (0.0, -25.0, [2.0, 0.0, 0.0], [1.75, 0.25, 0.25]), + (0.0, 100.0, [140.0 / 255.0, 115.0 / 255.0, 128.0 / 255.0], [6.77432, -5.77432, 0.750976]), + (0.0, 100.0, [2.0, 0.0, 0.0], [128.996, -126.996, -126.996]), + ] { + let adj = Adjustment::HueSaturation { hue, saturation: sat, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + let got = hdr(&adj, c); + assert!(near(got, want), "hue {hue}, saturation {sat}, {c:?}: {got:?}, Photoshop {want:?}"); + } + // A master plus a range edit that add up to a whole step stay on it: Reds +96 over a master + // -46 scales red as much as a master +50 alone (in f32 the sum is 0.49999997). + let mut ranges = HueRange::defaults(); + ranges[0].saturation = 96.0; + let summed = Adjustment::HueSaturation { hue: 0.0, saturation: -46.0, lightness: 0.0, colorize: false, ranges }; + let single = Adjustment::HueSaturation { hue: 0.0, saturation: 50.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + assert_eq!(hdr(&summed, [1.0, 0.0, 0.0]), hdr(&single, [1.0, 0.0, 0.0])); + // Lightness 0 with a negative channel divides by zero the same way. + let adj = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + assert!(hdr(&adj, [0.25, -0.25, 0.0]).iter().all(|v| v.is_finite())); + } + /// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast /// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the /// constant, and the Black & White tint keeps values above 1. @@ -1729,6 +1911,66 @@ mod tone_tests { } } + #[test] + fn color_balance_curve_matches_the_per_sample_formula_bitwise() { + // The curve works out its sample-independent terms once; this is the formula it replaced, + // evaluated whole for every sample. + fn per_sample(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + let (b, w) = (black / 255.0, 1.0 - white / 255.0); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; + } + if v <= b { + return 0.0; + } + let (u, gamma) = ((v - b) / (w - b), 2f32.powf(m / 100.0)); + let e = 1.0 / gamma.max(0.01); + let power = u.powf(e); + if gamma <= 1.0 { + return power; + } + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + if u0.is_nan() || u0 <= 0.0 || u >= u0 { + return power; + } + let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) + } + let mut seed = 0x9e37_79b9_u32; + let mut slider = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed % 241) as f32 - 120.0 + }; + let mut settings = vec![[[0.0; 3]; 3], [[f32::NAN, 1e9, -1e9]; 3], [[-100.0; 3], [100.0; 3], [100.0; 3]]]; + settings.extend((0..120).map(|_| std::array::from_fn(|_| std::array::from_fn(|_| slider())))); + let samples: Vec = (0..=1024).map(|i| i as f32 / 1024.0).chain([-0.5, 1.5, f32::NAN, f32::INFINITY]).collect(); + for tones in settings { + for preserve in [false, true] { + for ch in [0, 1, 2, 7] { + let curve = BalanceCurve::new(tones, preserve, ch); + for &v in &samples { + let (got, want) = (curve.eval(v), per_sample(tones, preserve, ch, v)); + assert_eq!(got.to_bits(), want.to_bits(), "{tones:?} {preserve} ch {ch} v {v}: {got} vs {want}"); + } + } + } + } + } + #[test] fn levels_and_curves_apply_each_channel_before_the_master() { // These two affine tone adjustments describe the same mapping: diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index c19b0dc92f..22f374af6e 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -266,6 +266,56 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() { } } +#[test] +fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() { + // #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The + // overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of + // yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain + // yellow. A half-transparent part of the layer mixes it in at half strength. + let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0]; + let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]]; + let half = |c: usize| (bg[c] + lit[c]) / 2.0; + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "green", full(&d), [bg[0], bg[1], bg[2], 1.0])); + let mut l = solid(&d, "fade", Rect::new(0, 0, 6, 8), [0.2, 0.6, 0.9, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), [0.2, 0.6, 0.9, 0.5])); + l.fill_opacity = 0.0; + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 1.0), color: Color::rgb(1.0, 1.0, 0.0) }]; + d.layers.push(l); + check(&d, 2, 2, [lit[0], lit[1], lit[2], 1.0], "opaque part"); + check(&d, 9, 2, [half(0), half(1), half(2), 1.0], "half-transparent part"); + } +} + +#[test] +fn a_see_through_normal_layer_without_interior_effects_counts_the_backdrop_once() { + // A half-transparent backdrop under a Normal layer at fill 50 % whose only effect is a drop + // shadow (cast off the canvas): plain Normal compositing, blue at 50 % over red at 50 %. The + // layer must not merge into the backdrop first, or the backdrop shows twice (alpha 0.875). + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5])); + let mut l = solid(&d, "blue", Rect::new(0, 0, 12, 4), BLUE); + l.fill_opacity = 0.5; + l.effects.items = vec![Effect::DropShadow(photocraft_doc::Shadow { + common: FxCommon::new(BlendMode::Normal, 1.0), + color: Color::BLACK, + angle: 90.0, + use_global_light: false, + distance: 20.0, + spread: 1.0, + size: 0.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + })]; + d.layers.push(l); + check(&d, 2, 2, [1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75], "inside the layer"); + } +} + #[test] fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { for depth in DEPTHS { @@ -289,6 +339,7 @@ fn inside_stroke() -> Effect { size: 2.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } @@ -298,6 +349,7 @@ fn outside_stroke() -> Effect { size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } @@ -318,6 +370,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() { size: 6.0, position, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, })]; l.blend = blend; l.fill_opacity = fill; diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..264dc3529b 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -37,6 +37,25 @@ pub fn has_effects(layer: &Layer) -> bool { layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled) } +/// Whether an enabled effect is laid out over the layer's frame ([`crate::effects_frame`]), so +/// growing or shrinking the layer repaints it everywhere, not only where its pixels changed: a +/// Gradient Overlay, a gradient stroke (its frame is the shape's extent), and patterns linked with +/// the layer without an effects reference point (overlays, pattern strokes and glows, and bevel +/// textures tile from the frame's top-left). +pub fn spans_frame(layer: &Layer) -> bool { + let unanchored = layer.effects.reference.is_none(); + let pattern = |p: &FxPaint| unanchored && matches!(p, FxPaint::Pattern { .. }); + has_effects(layer) + && layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::GradientOverlay { .. } => true, + Effect::PatternOverlay { link, .. } => *link && unanchored, + Effect::Stroke(s) => matches!(s.paint, FxPaint::Gradient(_)) || pattern(&s.paint), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => pattern(&g.paint), + Effect::BevelEmboss(b) => unanchored && b.texture.as_ref().is_some_and(|t| t.link), + _ => false, + }) +} + /// Pixels an effect stack can reach beyond the layer's shape. pub fn margin(layer: &Layer) -> i32 { let mut m = 0.0f32; @@ -68,13 +87,18 @@ pub const MAX_REACH: f32 = 512.0; /// shape's extent), Precise glows and chiselled bevels (exact Euclidean distances, whose f32 /// arithmetic depends on where the region starts) and noise (it speckles any coverage above /// zero, so it would pick up the blur's float noise, which depends on where the region starts). +/// Dissolve is not local for the same reason: it turns any coverage above zero into a chance of +/// a full pixel. pub(crate) fn maps_are_local(layer: &Layer) -> bool { - layer.effects.items.iter().filter(|e| e.enabled()).all(|e| match e { - Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)), - Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0, - Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0, - Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth, - _ => true, + layer.effects.items.iter().filter(|e| e.enabled()).all(|e| { + !dissolves(e) + && match e { + Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)), + Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0, + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0, + Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth, + _ => true, + } }) } @@ -978,7 +1002,9 @@ pub fn hash_noise(x: i64, y: i64) -> f32 { } /// Effect noise (Photoshop's Noise slider): monochrome speckle in the coverage, -/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched. +/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched. The speckle is +/// scaled by the coverage, so it fades out with the effect instead of lighting up the blur's +/// near-zero tail out to the edge of its rectangular reach (#7833). fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) { if !amount.is_finite() || amount <= 0.0 { return; @@ -990,7 +1016,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) { continue; } let n = hash_noise(i64::from(x0 + x as i32), i64::from(y0 + y as i32)); - *v = (*v + (n - 0.5) * 2.0 * amount).clamp(0.0, 1.0); + *v = (*v * (1.0 + (n - 0.5) * 2.0 * amount)).clamp(0.0, 1.0); } } } @@ -998,12 +1024,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) { fn apply_contour(m: Map, c: &Contour) -> Map { match contour_lut(c) { None => m, - Some(l) => m.map(|v| { - let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32; - let i = x.floor() as usize; - let j = (i + 1).min(l.len() - 1); - l[i] + (l[j] - l[i]) * (x - i as f32) - }), + Some(l) => m.map(|v| lut_at(&l, v)), } } @@ -1108,16 +1129,43 @@ fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] { /// Composite `color × coverage` into `dst` (both over the same rectangle). fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: BlendMode, opacity: f32) { + let (x0, y0, w) = (i64::from(dst.rect.x0), i64::from(dst.rect.y0), (dst.rect.width().max(1)) as usize); for (i, p) in dst.px.iter_mut().enumerate() { let a = m.v[i] * opacity; if a <= 0.0 { continue; } let c = color(i); + if blend == BlendMode::Dissolve { + if dissolves_in(x0 + (i % w) as i64, y0 + (i / w) as i64, c[3] * a) { + *p = psblend::composite(BlendMode::Normal, *p, [c[0], c[1], c[2], 1.0], 1.0); + } + continue; + } *p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0); } } +/// Whether `e` paints anything in Dissolve mode. +fn dissolves(e: &Effect) -> bool { + let d = |m: BlendMode| m == BlendMode::Dissolve; + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => d(s.common.blend), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => d(g.common.blend), + Effect::Stroke(s) => d(s.common.blend), + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => d(common.blend), + Effect::Satin(s) => d(s.common.blend), + Effect::BevelEmboss(b) => d(b.highlight.blend) || d(b.shadow.blend), + } +} + +/// Dissolve on an effect (#6995): a pixel takes the effect fully or not at all, with probability +/// `coverage`, picked by the same document-coordinate noise as a Dissolve layer. A soft glow or +/// shadow turns into scattered speckle instead of a smooth falloff. +fn dissolves_in(x: i64, y: i64, coverage: f32) -> bool { + hash_noise(x, y) < coverage +} + fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity: f32) { paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity); } @@ -1329,11 +1377,23 @@ fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) { } return; } - let mut inner: f64 = (x0 - (r - 1)..=x0 + (r - 1)).map(at).sum(); + let window = x0 - (r - 1)..=x0 + (r - 1); + let mut inner: f64 = window.clone().map(at).sum(); + let mut last_covered = window.rev().find(|&x| at(x) != 0.0).unwrap_or(i64::MIN); for (k, d) in dst.iter_mut().enumerate() { let x = x0 + k as i64; *d = (inner + f * (at(x - r) + at(x + r))) * norm; - inner += at(x + r) - at(x - r + 1); + let entering = at(x + r); + inner += entering - at(x - r + 1); + if entering != 0.0 { + last_covered = x + r; + } + // Cancellation can leave positive residue after the last covered pixel leaves. + // Noise amplifies even that residue into visible speckles (#5638). Reset only + // truly empty windows, preserving arbitrarily faint real coverage. + if last_covered < x - r + 2 { + inner = 0.0; + } } } @@ -1509,13 +1569,16 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern tent(&mut h, g.width); h } else { - let sharp = sharp_edges(shape); - let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); - let mut h = Map::new(shape.w, shape.h, 0.0); + let [din, dout] = bevel_dists(shape, size); let soft = g.chisel_soft > 0.0; - for i in 0..h.v.len() { - h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); - } + let w = shape.w; + let v = par_fill(w, shape.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); + } + }); + let mut h = Map { w, h: shape.h, v }; // Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel // Hard's across a straight edge); emboss styles keep the blur until measured. if soft && g.paint == BevelPaint::Both { @@ -1536,14 +1599,23 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0); let k = t.depth / unit; let (x0, y0) = (tex.rect.x0, tex.rect.y0); - for y in 0..h.h { - for x in 0..h.w { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); - let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; - h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l }; - } - } + let w = h.w; + par_units( + h.v.len(), + &mut h.v, + w, + &mut [] as &mut [()], + 0, + || (), + |_, y, row, _| { + for (x, o) in row.iter_mut().enumerate() { + let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); + let p = tile.sample(u, v); + let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; + *o += k * if t.invert { 1.0 - l } else { l }; + } + }, + ); } if b.soften >= 1.0 { tent(&mut h, b.soften); @@ -1623,26 +1695,42 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let on_bevel = |x: i64, y: i64| { hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS)) }; + // The gloss contour remaps the shading `n · light` before it splits into highlight (above + // what it maps a flat face's `sin(altitude)` to) and shadow. Applied to the highlight and shadow amounts + // instead, a contour that starts high (Rolling Slope - Descending) lit every pixel the light + // missed, so a bevelled glyph's flat face took the full highlight (#7360) where Photoshop + // leaves it at the layer's colour. The Linear contour changes nothing either way. + let gloss = contour_lut(&b.gloss_contour); let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) { let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); - for y in 0..shape.h as i64 { - for x in 0..shape.w as i64 { - let i = y as usize * shape.w + x as usize; - let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; - let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; - let n = [-gx, -gy, 1.0]; - let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); - let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; - let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; - let k = shade - se; - if k > 0.0 { - hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; - } else { - sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + // Rows on threads: every pixel reads only `hmap` and `shape`. + par_units( + hi.v.len(), + &mut hi.v, + shape.w, + &mut sh.v, + shape.w, + || (), + |_, y, hi_row, sh_row| { + let y = y as i64; + for x in 0..shape.w as i64 { + let i = y as usize * shape.w + x as usize; + let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; + let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; + let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; + let k = gloss.as_ref().map_or(shade - se, |l| lut_at(l, shade) - lut_at(l, se)); + if k > 0.0 { + hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; + } else { + sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + } } - } - } - (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) + }, + ); + (hi, sh) }; let maps = match g.paint { BevelPaint::Inner | BevelPaint::Outer => { @@ -1657,7 +1745,13 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let (hi, sh) = shade_into(g.depth, 0); let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1); let mix = |a: Map, b: Map| -> Map { - let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect(); + let v = par_fill(a.w, a.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * a.w + x; + let (x, y, s) = (a.v[i], b.v[i], shape.v[i]); + *o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0)); + } + }); Map { w: a.w, h: a.h, v } }; vec![mix(hi, ho), mix(sh, so)] @@ -1765,6 +1859,29 @@ pub fn build_maps(layer: &Layer, shape: Vec, rect: Rect, light: &GlobalLigh build_maps_prepared(layer, shape, rect, light, tex, &prepared) } +/// How many region-sized arrays [`build_maps_prepared`] builds for `layer` (what +/// [`FxMaps::bytes`] counts): the shape, each effect's maps, and a stroke's distance fields. Kept +/// next to the builder so the effect cache's estimate (#8330) follows it. +pub(crate) fn map_count(layer: &Layer) -> usize { + let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); + let per: usize = items + .iter() + .map(|e| match e { + Effect::DropShadow(_) | Effect::InnerShadow(_) | Effect::OuterGlow(_) | Effect::InnerGlow(_) | Effect::Satin(_) => 1, + Effect::BevelEmboss(_) => 2, + _ => 0, + }) + .sum(); + let out_widths: Vec = + items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(stroke_widths(st).1) } else { None }).filter(|w| *w > 0.0).collect(); + let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_))); + let outside = usize::from(out_widths.iter().any(|&w| !stroke_outside_exact(w))) + usize::from(out_widths.iter().any(|&w| stroke_outside_exact(w))); + let stroke = if has_stroke { 1 + outside } else { 0 }; + let vector_outline = + if has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none() { outside } else { 0 }; + 1 + per + stroke + vector_outline +} + pub(crate) fn build_maps_prepared( layer: &Layer, shape: Vec, @@ -1864,9 +1981,33 @@ pub fn shadow_see_through(layer: &Layer) -> f32 { /// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its /// own pixels only, and the interior effects paint over the blended result. The layer is merged /// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer -/// tints the grey beneath it (#3996). Normal-like modes give the same result either way. +/// tints the grey beneath it (#3996). A Normal layer merges only when an interior effect blends +/// in another mode: below 100 % fill that effect sees the backdrop through the layer, so a Soft +/// Light overlay on a Fill 0 % layer soft-lights the backdrop instead of showing its own colour +/// (#6232). Normal effects give the same result either way, and the merge would count a +/// half-transparent backdrop twice, so those layers keep the plain composite. pub fn premerges_layer(layer: &Layer) -> bool { - !layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve) + !layer.advanced.blend_interior + && match layer.blend { + BlendMode::PassThrough | BlendMode::Dissolve => false, + BlendMode::Normal => blends_inside(layer), + _ => true, + } +} + +/// `true` if an enabled effect painted inside the layer (with its content) blends in a mode +/// other than Normal. +fn blends_inside(layer: &Layer) -> bool { + let blends = |m: BlendMode| m != BlendMode::Normal; + layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => blends(common.blend), + Effect::Satin(s) => blends(s.common.blend), + Effect::InnerGlow(g) => blends(g.common.blend), + Effect::InnerShadow(s) => blends(s.common.blend), + Effect::Stroke(s) => stroke_widths(s).0 > 0.0 && blends(s.common.blend), + Effect::BevelEmboss(b) => bevel_geom(b).paint == BevelPaint::Inner && (blends(b.highlight.blend) || blends(b.shadow.blend)), + Effect::DropShadow(_) | Effect::OuterGlow(_) => false, + }) } /// Composites `content` (the layer's own pixels over `big`, alpha already @@ -2110,13 +2251,20 @@ pub(crate) fn composite_with_effects_prepared( } else { band }; - *sh = k * st.common.opacity * (1.0 - cover[i]); + 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; } // 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() }; - paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns); + // 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); for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) { if *s > 0.0 { for c in 0..3 { @@ -2172,7 +2320,13 @@ pub(crate) fn composite_with_effects_prepared( 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] = mix_premul(a, b, op); + 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) + }; } } } @@ -2290,6 +2444,18 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac } } +/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its +/// [`distance_field`], with the sharp edges they both start from found once. +pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { + bevel_dists(&Map { w, h, v: alpha }, reach) +} + +/// The inside and outside bevel distances of `s`, from one set of sharp edges. +fn bevel_dists(s: &Map, reach: f32) -> [Vec; 2] { + let sharp = sharp_edges(s); + [bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)] +} + #[cfg(test)] mod tests { use super::*; @@ -2462,6 +2628,7 @@ mod tests { altitude: 30.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: photocraft_doc::FxCommon::new(BlendMode::Screen, 0.75), highlight_color: photocraft_color::Color::WHITE, shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75), @@ -2932,6 +3099,55 @@ mod tests { } } + #[test] + fn bevel_maps_do_not_depend_on_the_threads() { + // Every style and technique, softened or not, and a texture; one thread (in order) is the reference. + let mut patchy = aa_disc(101, 101, 40.0); + for (i, v) in patchy.v.iter_mut().enumerate() { + if (i % 101 / 13 + i / 101 / 17) % 5 == 0 { + *v *= 0.6; + } + } + let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy]; + let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]); + sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]); + let pats = [Pattern::new("stripes", sf, 4, 4)]; + let light = GlobalLight::default(); + let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss]; + for (k, shape) in shapes.iter().enumerate() { + let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) }; + for style in styles { + for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] { + for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] { + let mut b = bevel_of(style, technique); + (b.size, b.soften) = (size, soften); + if textured { + b.texture = Some(photocraft_doc::BevelTexture { + name: "stripes".into(), + id: String::new(), + scale: 1.0, + depth: 1.0, + invert: false, + link: true, + phase: (0.0, 0.0), + }); + } + let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0; + let one = with_threads(1, run); + for threads in [2, 5, 16] { + let many = with_threads(threads, run); + assert_eq!(one.len(), many.len()); + for (j, (a, m)) in one.iter().zip(&many).enumerate() { + assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads"); + } + } + } + } + } + } + } + /// The four fields that use the distance transform, of `m`. fn distance_field_of(m: &Map, reach: f32) -> Vec> { [FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside] @@ -3193,6 +3409,37 @@ mod tests { assert!(heavy.v.iter().any(|v| *v < 0.5) && heavy.v.iter().any(|v| *v > 0.5), "heavy noise spans the range",); } + #[test] + fn box_blur_preserves_faint_coverage_and_empty_gaps() { + let src = [0.0, 0.3, 0.7, 0.0, 0.0, 0.0, 0.0, 1e-20, 0.0, 0.0, 0.0]; + let mut actual = [0.0; 11]; + box_line(&src, 0, &mut actual, 3.0); + assert_eq!(actual[5], 0.0, "an empty window must be exactly empty"); + assert_eq!(actual[10], 0.0, "the second tail must also be empty"); + assert!((actual[7] - 1e-20 / 3.0).abs() < 1e-30, "real faint coverage is not rounded away"); + } + + #[test] + fn effect_noise_does_not_light_up_empty_blur_tails() { + // A sloping antialiased shape leaves rounding residue in a running-sum blur. + // The direct finite-support convolution is the oracle for untouched pixels. + for size in [5.0, 5.5, 13.0] { + let mut shape = Map::new(96, 64, 0.0); + for y in 8..48 { + for x in 8..48 { + shape.v[y * shape.w + x] = ((x as f32 - y as f32 * 0.7) * 0.3).clamp(0.0, 1.0); + } + } + let mut direct = shape.clone(); + tent_direct(&mut direct, size); + let mut actual = shape; + blur(&mut actual, size); + noise(&mut actual, 0.26, 0, 0); + let stray = direct.v.iter().zip(&actual.v).filter(|(reference, noisy)| **reference == 0.0 && **noisy > 1e-5).count(); + assert_eq!(stray, 0, "noise created {stray} visible pixels outside a size-{size} blur"); + } + } + #[test] fn glow_noise_follows_the_contour_and_range() { let g = |noise: f32| Glow { @@ -3214,6 +3461,56 @@ mod tests { assert!(diff > 5.0, "glow noise changes the coverage: {diff}"); } + /// Issue #7833: the speckle stays inside the glow's visible falloff instead of filling the + /// blur's whole rectangular reach out to the map edge. + #[test] + fn effect_noise_stays_inside_the_visible_falloff() { + let mut shape = Map::new(80, 80, 0.0); + for y in 35..45 { + for x in 35..45 { + shape.v[y * 80 + x] = 1.0; + } + } + let g = |noise: f32| Glow { + common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0), + paint: FxPaint::Color(photocraft_color::Color::WHITE), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 10.0, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise, + source: GlowSource::Edge, + }; + let s = |noise: f32| Shadow { + common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0), + color: photocraft_color::Color::BLACK, + angle: 0.0, + use_global_light: false, + distance: 0.0, + spread: 0.0, + size: 10.0, + contour: Contour::Linear, + anti_alias: false, + noise, + knocks_out: true, + }; + let light = GlobalLight::default(); + let pairs = [ + ("glow", glow_map(&shape, &g(0.0), false, (0, 0)), glow_map(&shape, &g(0.5), false, (0, 0))), + ("shadow", shadow_map(&shape, &s(0.0), &light, false, (0, 0)), shadow_map(&shape, &s(0.5), &light, false, (0, 0))), + ]; + for (name, plain, noisy) in pairs { + for (i, (p, n)) in plain.v.iter().zip(&noisy.v).enumerate() { + if *p < 1.0 / 255.0 { + assert!(*n <= 2.0 / 255.0, "{name}: pixel ({}, {}) invisible without noise: {p} -> {n}", i % 80, i / 80); + } + } + } + } + #[test] fn shifting_by_hostile_distances_does_not_overflow() { let m = square_shape(12); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..e74cfd6d2f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -31,6 +31,7 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; +mod uniform; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; @@ -96,6 +97,59 @@ impl Buffer { /// Tile size for parallel rendering (tile results are independent). pub const RENDER_TILE: i32 = 256; +// One tile of scratch per worker; nested rendering takes its own buffer. +thread_local! { + static SCRATCH: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +fn scratch_buffer(rect: Rect) -> Buffer { + if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize { + return Buffer::transparent(rect); + } + let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut())); + px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]); + Buffer { rect, px } +} + +fn recycle_buffer(buf: Buffer) { + if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize { + SCRATCH.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.capacity() < buf.px.capacity() { + *slot = buf.px; + } + }); + } +} + +#[cfg(test)] +#[test] +fn scratch_reuse_preserves_pixels_and_bounds_retention() { + SCRATCH.with(|slot| slot.borrow_mut().clear()); + let rect = Rect::new(-2, -3, 18, 11); + for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] { + let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]); + let buf = surface_to_scratch(&s, rect); + assert_eq!(buf, surface_to_buffer(&s, rect)); + let ptr = buf.px.as_ptr(); + recycle_buffer(buf); + let reused = surface_to_scratch(&Surface::new(fmt), rect); + assert_eq!(reused.px.as_ptr(), ptr); + assert!(reused.px.iter().all(|p| p == &[0.0; 4])); + let nested = scratch_buffer(rect); + assert_ne!(nested.px.as_ptr(), reused.px.as_ptr()); + recycle_buffer(nested); + recycle_buffer(reused); + } + let cached = SCRATCH.with(|slot| slot.borrow().as_ptr()); + let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE)); + recycle_buffer(huge); + SCRATCH.with(|slot| { + assert_eq!(slot.borrow().as_ptr(), cached); + assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize); + }); +} + /// Composite the whole document over `rect`, in parallel 256² tiles on /// native targets (single-threaded on wasm). pub fn render(doc: &Document, rect: Rect) -> Buffer { @@ -193,14 +247,18 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer } }); } else { - // Few, wide bands: the tiles of each band in parallel. - for (i, band) in out.px.chunks_mut(band_len).enumerate() { + // Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's + // slowest tile doesn't leave the other cores idle until the next band starts. Each + // tile is written as soon as it is done, so only the tiles in flight are held. + out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { let y0 = rect.y0 + i as i32 * tile; - let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); - for part in &parts { - put(band, y0, part); - } - } + let band = std::sync::Mutex::new(band); + band_tiles(y0).into_par_iter().for_each(|t| { + let part = run(t); + let mut band = band.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + put(&mut band, y0, &part); + }); + }); } } #[cfg(target_arch = "wasm32")] @@ -341,6 +399,8 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + fx_over_budget: false, + tone_luts: prepare_tones(std::slice::from_ref(layer), 0), doc: None, layer_keys: Default::default(), }, @@ -349,6 +409,31 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { buf } +/// [`render_layer`] in `doc`'s context: the layer alone over `rect`, with the document's patterns, +/// Global Light, colour mode, depth and canvas (fill layers and gradients are laid out on it), as +/// the canvas composites it. Bakes (Scripts › Flatten All Layer Effects) render through this so +/// the pixels match what was on screen (#5719). Rects past [`MAX_RENDER_PIXELS`] give an empty +/// buffer. +pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { + if rect.is_empty() { + return Buffer::transparent(rect); + } + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + // A lone layer: a deep knockout reveals transparency, not the document's Background. + let cx = Ctx { doc: None, ..Ctx::for_doc(doc, &patterns) }; + let lab = doc.mode == photocraft_color::ColorMode::Lab; + let hdr = doc.depth == photocraft_color::SampleType::F32; + let cmyk = cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let mut buf = Buffer::transparent(rect); + in_blend_space(lab, hdr, || composite_stack_at(std::slice::from_ref(layer), &mut buf, &cx, advanced::Scope::ROOT)); + buf + }) +} + /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20; @@ -406,6 +491,38 @@ pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Bu /// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an /// explicit band height (see [`render_bands`]). fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32) -> Buffer { + render_reduced_with(doc, b, w, h, damage, band_rows, true, &mut ReducedStats::default()) +} + +/// What a reduced render composited (tests check that one-colour areas are not composited). +#[derive(Debug, Default)] +struct ReducedStats { + /// Whether one-colour areas were looked for ([`uniform::UniformAreas`]). + uniform: bool, + /// Document pixels composited one by one. + composited: u64, +} + +/// A tile of a reduced render: one colour (premultiplied), or its composited pixels. +enum Part { + Solid([f32; 4]), + Px(Buffer), +} + +/// A column of document tiles in a reduced render (`b`-relative source columns). +struct TileColumn { + /// The columns rendered: the tile's, within the source area. + x0: usize, + x1: usize, + /// The whole tile's columns within `b`, whose values decide whether it is one colour. + whole: (i64, i64), + /// Runs of (output column, how many of the rendered columns it averages). + runs: Vec<(usize, f32)>, +} + +/// [`render_reduced_in_bands`]; `uniform` off composites every pixel (the reference in tests). +#[allow(clippy::too_many_arguments)] +fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32, uniform: bool, stats: &mut ReducedStats) -> Buffer { let (fw, fh) = (b.width() as usize, b.height() as usize); let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); let full = Rect::from_xywh(0, 0, w as u32, h as u32); @@ -449,22 +566,14 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti let (cols, rows) = (map(fw, w), map(fh, h)); let cols = &cols[sx0..sx1]; let mut acc = vec![[0.0f32; 4]; ow * oh]; - let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { - let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + // Each output row sums its source rows in a band (in order, top to bottom), each row's pixels + // left to right: `row(y, out)` adds source row `y` (band-relative) to output row `out`. + let sum_band = |acc: &mut [[f32; 4]], by0: usize, by1: usize, row: &(dyn Fn(usize, &mut [[f32; 4]]) + Sync)| { let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1); - let sw = sx1 - sx0; - // Each output row sums its source rows in this band (in order, top to bottom). let sum_row = |t: usize, out: &mut [[f32; 4]]| { let (a, z) = span(t, fh, h); for y in a.max(by0)..z.min(by1) { - let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw]; - for (p, &c) in src.iter().zip(cols) { - let o = &mut out[c as usize - ox0]; - for i in 0..3 { - o[i] += p[i] * p[3]; - } - o[3] += p[3]; - } + row(y - by0, out); } }; let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow]; @@ -477,14 +586,137 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti for (i, out) in part.chunks_mut(ow).enumerate() { sum_row(t0 + i, out); } - Ok(()) - }); - debug_assert!(ok.is_ok()); - for (i, p) in acc.iter_mut().enumerate() { - let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h)); - let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; - let a = p[3]; - *p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] }; + }; + let add = |o: &mut [f32; 4], p: &[f32; 4]| { + for i in 0..3 { + o[i] += p[i] * p[3]; + } + o[3] += p[3]; + }; + if let Some(uniform) = uniform.then(|| uniform::UniformAreas::new(doc)).flatten() { + stats.uniform = true; + // One-colour tiles (a new document's background, the empty parts of a sparse one) are + // composited once per colour and summed per run of source columns, not per pixel + // (#4017). Tiles lie on the document's tile grid whatever `damage` is, so a damage + // render sums exactly what the whole reduction does. + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(doc, &patterns); + let t = RENDER_TILE as usize; + // The document tile grid (the surfaces' own), in `b`-relative coordinates: the start of + // the tile holding `v` (negative when `b` starts inside a tile). + let grid = |v: usize, o: i32| (i64::from(o) + v as i64).div_euclid(t as i64) * t as i64 - i64::from(o); + let tile_cols: Vec = (grid(sx0, b.x0)..sx1 as i64) + .step_by(t) + .map(|gx| { + let (x0, x1) = ((gx.max(0) as usize).max(sx0), ((gx + t as i64) as usize).min(sx1)); + let mut runs: Vec<(usize, f32)> = Vec::new(); + for &c in &cols[x0 - sx0..x1 - sx0] { + match runs.last_mut() { + Some((last, n)) if *last == c as usize - ox0 => *n += 1.0, + _ => runs.push((c as usize - ox0, 1.0)), + } + } + TileColumn { x0, x1, whole: (gx.max(0), (gx + t as i64).min(fw as i64)), runs } + }) + .collect(); + let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize; + let mut y = grid(sy0, b.y0); + while y < sy1 as i64 { + let y1 = (y + band_h as i64).min(sy1 as i64); + let tiles: Vec<(Rect, Rect)> = (y..y1) + .step_by(t) + .flat_map(|gy| { + tile_cols.iter().map(move |&TileColumn { x0, x1, whole: (gx0, gx1), .. }| { + let at = |x0: i64, y0: i64, x1: i64, y1: i64| Rect::new(b.x0 + x0 as i32, b.y0 + y0 as i32, b.x0 + x1 as i32, b.y0 + y1 as i32); + let (gy0, gy1) = (gy.max(0), (gy + t as i64).min(fh as i64)); + (at(gx0, gy0, gx1, gy1), at(x0 as i64, gy0.max(sy0 as i64), x1 as i64, gy1.min(sy1 as i64))) + }) + }) + .collect(); + let one = |(whole, part): &(Rect, Rect)| -> Part { + let render = |r: Rect| render_tiled_with(doc, r, RENDER_TILE, &cx); + match uniform.color(*whole, |r| render(r).px.first().copied()) { + Some(p) => Part::Solid([p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]), + None => Part::Px(render(*part)), + } + }; + #[cfg(not(target_arch = "wasm32"))] + let parts: Vec = { + use rayon::prelude::*; + tiles.par_iter().map(one).collect() + }; + #[cfg(target_arch = "wasm32")] + let parts: Vec = tiles.iter().map(one).collect(); + stats.composited += parts.iter().map(|p| if let Part::Px(b) = p { b.px.len() as u64 } else { 0 }).sum::(); + let (by0, by1) = (y.max(sy0 as i64) as usize, y1 as usize); + let n = tile_cols.len(); + let band_y = y; + sum_band(&mut acc, by0, by1, &|row: usize, out: &mut [[f32; 4]]| { + let y = (by0 + row) as i64; + let j = (y - band_y) as usize / t; + let ty = (y - (band_y + (j * t) as i64).max(sy0 as i64)) as usize; + for (TileColumn { x0, x1, runs, .. }, part) in tile_cols.iter().zip(parts.iter().skip(j * n).take(n)) { + match part { + Part::Solid(pa) => { + for &(c, k) in runs { + let o = &mut out[c]; + for i in 0..4 { + o[i] += pa[i] * k; + } + } + } + Part::Px(buf) => { + let tw = x1 - x0; + for (p, &c) in buf.px.iter().skip(ty * tw).take(tw).zip(&cols[x0 - sx0..x1 - sx0]) { + add(&mut out[c as usize - ox0], p); + } + } + } + } + }); + y = y1; + } + } else { + let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { + stats.composited += band.px.len() as u64; + let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + let sw = sx1 - sx0; + sum_band(&mut acc, by0, by1, &|y: usize, out: &mut [[f32; 4]]| { + for (p, &c) in band.px[y * sw..(y + 1) * sw].iter().zip(cols) { + add(&mut out[c as usize - ox0], p); + } + }); + Ok(()) + }); + debug_assert!(ok.is_ok()); + } + let col_w: Vec = (0..ow) + .map(|x| { + let (x0, x1) = span(ox0 + x, fw, w); + (x1 - x0).max(1) + }) + .collect(); + let norm_row = |row: usize, out: &mut [[f32; 4]]| { + let (y0, y1) = span(oy0 + row, fh, h); + let rh = (y1 - y0).max(1); + for (p, &cw) in out.iter_mut().zip(&col_w) { + let a = p[3]; + if a > 0.0 { + let n = (rh * cw) as f32; + *p = [p[0] / a, p[1] / a, p[2] / a, a / n]; + } else { + *p = [0.0; 4]; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + acc.par_chunks_mut(ow).enumerate().for_each(|(row, out)| norm_row(row, out)); + } + #[cfg(target_arch = "wasm32")] + for (row, out) in acc.chunks_mut(ow).enumerate() { + norm_row(row, out); } Buffer { rect: out, px: acc } } @@ -531,6 +763,8 @@ struct Ctx<'a> { /// The document's colour mode (channel restrictions name its channels). mode: photocraft_color::ColorMode, depth: photocraft_color::SampleType, + /// At most 1 MiB of tone tables, initialized once and shared by this render's tiles and bands. + tone_luts: Vec<(&'a photocraft_doc::Adjustment, std::sync::OnceLock<[Vec; 4]>)>, vector_masks: RenderVectorMasks, /// Effect maps used by this render, by cache key: built before the parallel tiles (see /// [`prepare_effects`]) and held for the whole render (or band, for maps clipped to it: the @@ -538,6 +772,9 @@ struct Ctx<'a> { fx_maps: std::sync::Mutex, bool)>>, /// The rectangle being rendered (a band of [`render_bands`]); see [`effect_region`]. fx_clip: std::sync::Mutex>, + /// The whole-region effect maps of this render's layers wouldn't fit in the effect cache + /// together ([`fx_over_budget`]), so local ones are clipped to the render rect (#8330). + fx_over_budget: bool, /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. doc: Option<&'a Document>, /// Effect-map identities of this render's document layers (see [`LayerKeys`]). @@ -609,7 +846,9 @@ impl<'a> Ctx<'a> { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + fx_over_budget: fx_over_budget(&doc.layers, doc.bounds()), layer_keys: Default::default(), + tone_luts: prepare_tones(&doc.layers, 0), } } @@ -621,9 +860,41 @@ impl<'a> Ctx<'a> { self.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).retain(|_, (_, clipped)| !*clipped); } } + + fn apply_adjustment(&self, adj: &photocraft_doc::Adjustment, buf: &mut Buffer) { + if let photocraft_doc::Adjustment::Levels { space, .. } | photocraft_doc::Adjustment::Curves { space, .. } = adj + && !matches!(adj, photocraft_doc::Adjustment::Levels { space: photocraft_doc::adjust::ToneSpace::Rgb, .. } if self.depth == photocraft_color::SampleType::F32) + && let Some((_, luts)) = self.tone_luts.iter().find(|(a, _)| std::ptr::eq(*a, adj)) + { + let identity = adjust::float_tone_identity(adj, Some(self.depth)); + if identity == [true; 3] { + return; + } + adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity); + } else { + adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode); + } + } } -/// Deepest group nesting [`prepare_effects`] walks (deeper layers build their maps on demand). +fn prepare_tones(layers: &[Layer], depth: u32) -> Vec<(&photocraft_doc::Adjustment, std::sync::OnceLock<[Vec; 4]>)> { + let mut out = Vec::new(); + for layer in layers.iter().filter(|l| l.visible) { + if out.len() == 16 { + break; + } + match &layer.content { + LayerContent::Adjustment(a @ (photocraft_doc::Adjustment::Levels { .. } | photocraft_doc::Adjustment::Curves { .. })) => { + out.push((a, Default::default())) + } + LayerContent::Group(g) if depth < PREPARE_DEPTH => out.extend(prepare_tones(&g.children, depth + 1).into_iter().take(16 - out.len())), + _ => {} + } + } + out +} + +/// Deepest group nesting preparation walks (deeper layers prepare on demand). const PREPARE_DEPTH: u32 = 64; /// Build the effect maps of every visible layer in `layers` (groups included) that can reach @@ -718,11 +989,41 @@ pub fn adjustment_quantum(depth: photocraft_color::SampleType) -> Option { fn quantize(b: &mut Buffer, q: f32) { for p in &mut b.px { for v in p.iter_mut() { - *v = (*v * q + 0.5).floor() / q; + *v = branchless_floor(*v * q + 0.5) / q; } } } +/// `x.floor()`, bit for bit (signed zeros, infinities and NaNs included), without a call: baseline +/// x86-64 has no SSE4.1 `roundss`, so `floor` is a software routine there, called once per sample +/// of the backdrop under every adjustment layer ([`quantize`]). This form has no branch, so the +/// loop vectorizes. +#[inline] +fn branchless_floor(x: f32) -> f32 { + // From 2^23 on every f32 is whole. Below it, adding and taking away 2^23 rounds to the + // nearest whole number; where that went up, step down. + const WHOLE: f32 = 8_388_608.0; + let a = x.abs(); + let r = ((a + WHOLE) - WHOLE).copysign(x); + let r = if r > x { r - 1.0 } else { r }; + if a < WHOLE { r } else { x } +} + +/// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each +/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`. +fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator, mode: BlendMode) { + for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) { + if k <= 0.0 { + continue; + } + let s = [a[0], a[1], a[2]]; + // Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment + // layers are Normal: no per-pixel mode dispatch for them. + let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) }; + *b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]]; + } +} + /// Deterministic hash for Dissolve (document-coordinate based, so it is stable under tiling). #[inline] fn dissolve_noise(x: i32, y: i32) -> f32 { @@ -803,11 +1104,13 @@ pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { /// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the /// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other /// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches -/// past the path (their pixels give the outline: psd-tools double-stroke-effects). +/// past the path (their pixels give the outline: psd-tools double-stroke-effects), and shapes +/// with a density or feather. pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> { let LayerContent::Shape(sh) = &layer.content else { return None }; let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside); - (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path) + // A soft (feathered or less dense) fill's own alpha is its outline. + (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted && !sh.has_soft_mask()).then_some(&sh.path) } /// The shape a layer's effects are built from over `rect` (row-major): its content's alpha @@ -865,8 +1168,41 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } -/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. -pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { +/// The frame a layer's effects are laid out in: [`paint_bounds`] for a shape, else the layer's +/// bounds ([`layer_bounds`]). A Gradient Overlay spans it, linked patterns tile from its corner. +pub fn effects_frame(layer: &Layer, canvas: Rect) -> Rect { + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)) +} + +/// The pixels a change from `a` to `b` repaints beyond those it changed: where a layer whose +/// effects span its frame ([`effects::spans_frame`]) moved that frame, the layer's whole composite +/// before and after. Painting on a layer with a Gradient Overlay lays the one gradient out again +/// over the grown layer, as Photoshop does after each stroke; damage that covers only the stroke +/// leaves the old gradient around earlier strokes (#4053). Empty when no such frame moved. +pub fn relayout_damage(a: &Document, b: &Document) -> Rect { + let mut out = Rect::EMPTY; + let mut add = |r: Rect| { + if !r.is_empty() { + out = if out.is_empty() { r } else { out.union(&r) }; + } + }; + for (_, _, y) in b.walk() { + if !y.visible || !effects::spans_frame(y) { + continue; + } + let Some(x) = a.layer(y.id) else { continue }; + if effects_frame(x, a.bounds()) == effects_frame(y, b.bounds()) { + continue; + } + add(change_bounds(x, a.bounds()).unwrap_or_else(|| a.bounds())); + add(change_bounds(y, b.bounds()).unwrap_or_else(|| b.bounds())); + } + out +} + +/// The bounding box of a shape layer's path and stroke, ignoring its Density and Feather: its +/// path control bounds plus stroke width (a conservative reach for any alignment, cap and join). +pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { return None; } @@ -876,6 +1212,89 @@ pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { (!r.is_empty()).then_some(r) } +/// The frame Free Transform, the Move tool's transform controls, snapping and Properties show for +/// a soft shape (Density below 100 % or a Feather), whose rendering reaches past its path: the +/// pixels the same shape renders in `format` over `canvas` at full Density without Feather, so +/// the frame is exactly the hard shape's whatever the stroke's alignment, cap and join (in +/// Photoshop a shape's transform frame follows its path and stroke whatever its mask Density and +/// Feather, #5846). An inverted path's frame is its own outline, not the whole canvas. Renders +/// only the strips along the edges of the rasterizers' (1 px conservative) bounds. +pub fn soft_shape_frame(sh: &photocraft_doc::ShapeLayer, format: photocraft_color::PixelFormat, canvas: Rect) -> Option { + let mut path = sh.path.clone(); + path.inverted = false; + let hard = photocraft_doc::ShapeLayer { density: 1.0, feather: 0.0, path, fill: sh.fill.clone(), stroke: sh.stroke.clone(), ..Default::default() }; + let shape = photocraft_vector::CompiledShape::new(&hard, photocraft_vector::DEFAULT_TOLERANCE, canvas); + let r = shape.bounds()?.intersect(&canvas); + trim_to_painted(r, |strip| shape.render(format, strip).content_bounds()) +} + +/// `r` shrunk to the pixels `painted` reports (the painted bounds within a strip of `r`, empty if +/// none), looking in from each edge through strips that double in thickness: a few renders per +/// edge however wide the empty margin (a subtracted or intersected subpath can leave most of the +/// rasterizers' bounds empty), and none of `r`'s inside rendered at once. +fn trim_to_painted(mut r: Rect, mut painted: impl FnMut(Rect) -> Rect) -> Option { + // Top: the first strip holding paint gives the first painted row. + let mut t = 1; + while !r.is_empty() { + let strip = Rect::new(r.x0, r.y0, r.x1, r.y0.saturating_add(t).min(r.y1)); + let p = painted(strip).intersect(&strip); + if !p.is_empty() { + r.y0 = p.y0; + break; + } + r.y0 = strip.y1; + t = t.saturating_mul(2); + } + let mut t = 1; + while !r.is_empty() { + let strip = Rect::new(r.x0, r.y1.saturating_sub(t).max(r.y0), r.x1, r.y1); + let p = painted(strip).intersect(&strip); + if !p.is_empty() { + r.y1 = p.y1; + break; + } + r.y1 = strip.y0; + t = t.saturating_mul(2); + } + let mut t = 1; + while !r.is_empty() { + let strip = Rect::new(r.x0, r.y0, r.x0.saturating_add(t).min(r.x1), r.y1); + let p = painted(strip).intersect(&strip); + if !p.is_empty() { + r.x0 = p.x0; + break; + } + r.x0 = strip.x1; + t = t.saturating_mul(2); + } + let mut t = 1; + while !r.is_empty() { + let strip = Rect::new(r.x1.saturating_sub(t).max(r.x0), r.y0, r.x1, r.y1); + let p = painted(strip).intersect(&strip); + if !p.is_empty() { + r.x1 = p.x1; + break; + } + r.x1 = strip.x0; + t = t.saturating_mul(2); + } + (!r.is_empty()).then_some(r) +} + +/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width +/// (and the reach of a soft fill). +pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { + let r = hard_shape_bounds(sh)?; + // A soft fill reaches past the path: by its feather, or everywhere below 100 % density. + if sh.fill.is_some() && sh.has_soft_mask() { + if sh.density.is_finite() && sh.density < 1.0 { + return Some(Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4)); + } + return Some(r.union(&photocraft_vector::feather::path_area(&sh.path, masks::feather_sigma(sh.feather)))); + } + Some(r) +} + /// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask /// times the rasterized vector mask. fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> { @@ -915,7 +1334,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { } LayerContent::Fill(f) => match &layer.fill_cache { // Photoshop's own rendering, valid while the fill is unchanged. - Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect), + Some(c) if c.fill == *f => surface_to_scratch(&c.surface, rect), _ if cx.mode == photocraft_color::ColorMode::Cmyk => { let frame = fill_frame(layer, cx.canvas); let native_rect = if matches!(f, Fill::Solid(_)) { Rect::new(0, 0, 1, 1) } else { rect }; @@ -929,7 +1348,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { }, LayerContent::Adjustment(_) => return None, _ => match layer.surface() { - Some(s) => surface_to_buffer(s, rect), + Some(s) => surface_to_scratch(s, rect), None => Buffer::transparent(rect), }, }; @@ -970,6 +1389,12 @@ pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer { Buffer { rect, px } } +fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer { + let mut buf = scratch_buffer(rect); + s.read_rgba_into(rect, &mut buf.px); + buf +} + /// The frame a fill layer's gradient is laid out in ("Align with layer", Photoshop's default): /// the layer's bounds, i.e. the area its masks reveal: a hide-all pixel mask's painted area, or /// the vector mask's path; otherwise the canvas. @@ -1156,9 +1581,14 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option { pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option { fn reaches_below(l: &Layer) -> bool { match &l.content { - // Clipped, it changes only its base's pixels. LayerContent::Adjustment(_) => !l.clipped, - LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below), + // Clipped to a base, an adjustment changes only the base's pixels; one with no base + // beneath it in its list composites as an ordinary adjustment (#6535). + LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().enumerate().any(|(k, c)| { + let orphan = + c.clipped && matches!(c.content, LayerContent::Adjustment(_)) && g.children.get(..k).is_none_or(|below| below.iter().all(|b| b.clipped)); + orphan || reaches_below(c) + }), _ => false, } } @@ -1168,6 +1598,35 @@ pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option { composite_bounds(layer, canvas) } +/// [`change_bounds`] of `layers[k]` in its sibling list (bottom→top), which also bounds a clipped +/// adjustment layer: it changes only its clip base's pixels, so it reaches as far as showing, +/// hiding or restyling that base does (#6535). Still `None` when there is no base below it (the +/// compositor then draws it as an ordinary adjustment) or the base is an adjustment itself +/// (layers clipped to one composite atop the adjusted backdrop, everywhere). `outer` is the list +/// holding the group whose children `layers` are, and that group's index there (`None` at the +/// top level): a pass-through group that is a clip base renders isolated while it holds a visible +/// adjustment, so showing or hiding one inside can change the whole group. +pub fn change_bounds_in(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option { + let layer = layers.get(k)?; + if layer.clipped && matches!(layer.content, LayerContent::Adjustment(_)) { + let base = layers.get(..k)?.iter().rev().find(|l| !l.clipped)?; + if matches!(base.content, LayerContent::Adjustment(_)) { + return None; + } + if let Some((outer, g)) = outer + && let Some(group) = outer.get(g) + && group.blend == BlendMode::PassThrough + && !group.clipped + && outer.get(g + 1).is_some_and(|above| above.clipped) + { + // The group bounds its base too. + return change_bounds(group, canvas); + } + return change_bounds(base, canvas); + } + change_bounds(layer, canvas) +} + /// Whether the layer's content is transparent outside its bounds (every surface it draws from /// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown /// by their reach. @@ -1350,6 +1809,38 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L } } +/// The surface `render_content` reads `layer`'s pixels from, when that is all it draws. +fn pixel_surface(layer: &Layer) -> Option<&Surface> { + if matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) { + return None; + } + layer.surface() +} + +/// A pixel layer (nothing clipped to it) onto `backdrop`, read from its tiles a row at a time: +/// the same arithmetic as `render_content` + `blend_into_fill`, without a tile-sized copy of the +/// layer (a megabyte per 256² render tile). +fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mut Buffer, cx: &Ctx) { + let rect = backdrop.rect; + let w = rect.width() as usize; + if w == 0 { + return; + } + let mv = mask_vals(layer, rect, cx); + let gamma = text_gamma(layer); + let mut row = vec![[0.0f32; 4]; w]; + for (ry, dst) in backdrop.px.chunks_exact_mut(w).enumerate() { + let y = rect.y0 + ry as i32; + surf.read_rgba_into(Rect::new(rect.x0, y, rect.x1, y + 1), &mut row); + if let Some(m) = mv.as_ref().and_then(|m| m.get(ry * w..(ry + 1) * w)) { + for (p, k) in row.iter_mut().zip(m) { + p[3] *= k; + } + } + blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma); + } +} + fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let rect = backdrop.rect; // A TSL-off stroke base that is transparent here can still have visible clipped siblings; @@ -1425,25 +1916,35 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Adjustment layers transform the backdrop, then blend the result back in. if let LayerContent::Adjustment(adj) = &layer.content { - let mut adjusted = backdrop.clone(); - adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode); + let mv = mask_vals(layer, rect, cx); + if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) { + return; + } + let mut adjusted = scratch_buffer(rect); + adjusted.px.copy_from_slice(&backdrop.px); + cx.apply_adjustment(adj, &mut adjusted); // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. advanced::composite_clipped(clipped, &mut adjusted, cx); - let mv = mask_vals(layer, rect, cx); - 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 k = opacity * mask_k(&mv, i); - if k <= 0.0 { - continue; + if layer.blend == BlendMode::Dissolve { + // An adjustment has no standalone source alpha. Its effective coverage comes from its + // own opacity and mask, not a continuous RGB mix (#3050). + 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 k = opacity * mask_k(&mv, i); + if k > 0.0 && dissolve_noise(x, y) < k { + let a = adjusted.px[i]; + backdrop.px[i] = [a[0], a[1], a[2], backdrop.px[i][3]]; + } } - // Only `adjusted` has changed; this pixel's original backdrop is still in place. - let b = backdrop.px[i]; - let a = adjusted.px[i]; - let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); - backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]]; + } + } else { + match &mv { + Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend), + None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend), } } + recycle_buffer(adjusted); return; } @@ -1468,9 +1969,36 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); return; } + if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) + && let Some(s) = layer.surface() + && layer.blend == BlendMode::Normal + && opacity >= 1.0 + && !clipped.iter().any(|c| c.visible) + && layer.mask.is_none() + && layer.vector_mask.is_none() + && text_gamma(layer) == 1.0 + && !psblend::LAB_MIX.with(|l| l.get()) + && backdrop.px.iter().all(|p| p[3] <= 0.0) + { + s.read_rgba_into(rect, &mut backdrop.px); + for p in &mut backdrop.px { + if p[3] <= 0.0 { + *p = [0.0; 4]; + } else if p[3] < 1.0 { + *p = psblend::composite_normal_rgb([0.0; 4], *p, 1.0); + } + } + return; + } + if clipped.is_empty() + && 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)); + recycle_buffer(content); } /// A layer with effects (and its clipping group) onto `backdrop`. @@ -1489,15 +2017,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff advanced::composite_clipped(clipped, &mut content, cx); let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - backdrop, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -1656,7 +2176,9 @@ fn fx_cache() -> &'static std::sync::Mutex { /// 1920 × 103675 webtoon page each such layer's maps took about 2.4 GB, and a render held them for /// every layer at once (60 GB with 25 stroked type layers, #1909). When its effects are local /// ([`effects::maps_are_local`]), such a layer's maps are built over the render rectangle padded -/// by twice the effect reach instead, which gives the same pixels there. Only when `rect`, what +/// by twice the effect reach instead, which gives the same pixels there. So are any layer's local +/// maps when the render's whole-region maps wouldn't fit in the effect cache ([`fx_over_budget`]). +/// Only when `rect`, what /// the caller composites the layer over, lies within the render rectangle: a group's effect shape /// (or anything else rendering children over a wider area) gets the whole region. fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { @@ -1664,7 +2186,7 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { let region = effect_source_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m)); let clip = *cx.fx_clip.lock().unwrap_or_else(|e| e.into_inner()); match clip { - Some(c) if c.contains_rect(&rect) && advanced::shapeless(layer) && effects::maps_are_local(layer) => { + Some(c) if c.contains_rect(&rect) && (advanced::shapeless(layer) || cx.fx_over_budget) && effects::maps_are_local(layer) => { let part = region.intersect(&c.inflate(m.saturating_mul(2))); (part, part != region) } @@ -1672,6 +2194,38 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { } } +/// Whether the effect maps of the visible layers of `layers` (groups included), each over its +/// whole region, would take more than the effect cache budget together. A render needs all of +/// them, so when they don't fit, building one evicts the next one needed and every render builds +/// them all again, seconds even for a small rect (#8330). Counted as [`effects::FxMaps::bytes`] +/// counts the built maps ([`effects::map_count`] arrays over each region). +fn fx_over_budget(layers: &[Layer], canvas: Rect) -> bool { + fx_region_bytes(layers, canvas) > effect_cache_budget() as u64 +} + +/// The bytes [`fx_over_budget`] compares with the budget. +fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 { + fn bytes(layers: &[Layer], canvas: Rect, depth: u32) -> u64 { + let mut total = 0u64; + for l in layers.iter().filter(|l| l.visible) { + if effects::has_effects(l) { + let m = effects::margin(l); + let region = effect_source_bounds(l, canvas).inflate(m).intersect(&canvas.inflate(m)); + let maps = effects::map_count(l) as u64; + let px = u64::from(region.width()) * u64::from(region.height()); + total = total.saturating_add(maps.saturating_mul(px).saturating_mul(4)); + } + if let LayerContent::Group(g) = &l.content + && depth < PREPARE_DEPTH + { + total = total.saturating_add(bytes(&g.children, canvas, depth + 1)); + } + } + total + } + bytes(layers, canvas, 0) +} + /// The layer's effect maps over its region (layer bounds grown by the effect reach, within the /// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every /// tile. `rect` is what the caller composites the layer over (see [`effect_region`]). @@ -1755,7 +2309,7 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s } fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> { - let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)); + let sb = effects_frame(layer, cx.canvas); effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) } } @@ -1780,11 +2334,19 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let rect = base.rect; if let LayerContent::Adjustment(adj) = &layer.content { let mut adjusted = base.clone(); - adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode); + cx.apply_adjustment(adj, &mut adjusted); let mv = mask_vals(layer, rect, cx); for (i, p) in base.px.iter_mut().enumerate() { let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i); let a = adjusted.px[i]; + if layer.blend == BlendMode::Dissolve { + let x = rect.x0 + (i % rect.width() as usize) as i32; + let y = rect.y0 + (i / rect.width() as usize) as i32; + if dissolve_noise(x, y) < k { + *p = [a[0], a[1], a[2], p[3]]; + } + continue; + } let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]); for c in 0..3 { p[c] += (bl[c] - p[c]) * k; @@ -1810,15 +2372,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - &mut opaque, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); @@ -1839,6 +2393,16 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { continue; } let s = content.px[i]; + if layer.blend == BlendMode::Dissolve { + // Clipping preserves the opaque base's alpha. Dissolve still selects source + // pixels by spatial coverage instead of continuously mixing the colours (#3052). + let x = rect.x0 + (i as i32 % rect.width() as i32); + let y = rect.y0 + (i as i32 / rect.width() as i32); + if dissolve_noise(x, y) < s[3] * opacity { + *p = [s[0], s[1], s[2], alpha]; + } + continue; + } // Blend as if the base were opaque, then keep the base's alpha. let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma); *p = [r[0], r[1], r[2], alpha]; @@ -1853,50 +2417,59 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32 /// [`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. fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { - if fill >= 1.0 || !psblend::fill_is_special(mode) { - return blend_into_g(backdrop, src, mode, opacity * fill, gamma); + for_rows(backdrop, src, |dst, s, x0, y| blend_span_fill(dst, s, x0, y, mode, opacity, fill, gamma)); +} + +/// [`blend_into`] mixing coverage in a `gamma` space (type layers). +fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { + for_rows(backdrop, src, |dst, s, x0, y| blend_span_g(dst, s, x0, y, mode, opacity, gamma)); +} + +/// Runs `f(backdrop row, src row, x0, y)` over the rows of two buffers covering the same rect. +fn for_rows(backdrop: &mut Buffer, src: &Buffer, mut f: impl FnMut(&mut [[f32; 4]], &[[f32; 4]], i32, i32)) { + let rect = backdrop.rect; + let w = (rect.width() as usize).max(1); + for (ry, (dst, s)) in backdrop.px.chunks_mut(w).zip(src.px.chunks(w)).enumerate() { + f(dst, s, rect.x0, rect.y0 + ry as i32); } - for (b, s) in backdrop.px.iter_mut().zip(&src.px) { +} + +/// [`blend_into_fill`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +#[allow(clippy::too_many_arguments)] +fn blend_span_fill(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { + if fill >= 1.0 || !psblend::fill_is_special(mode) { + return blend_span_g(dst, src, x0, y, mode, opacity * fill, gamma); + } + for (b, s) in dst.iter_mut().zip(src) { if s[3] > 0.0 { *b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma); } } } -/// [`blend_into`] mixing coverage in a `gamma` space (type layers). -fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { +/// [`blend_into_g`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, gamma: f32) { // The blend space is constant in this loop. Some TLS implementations synchronise even a // boolean read, so select the common Normal kernel once instead of once per pixel. if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) { - for (i, b) in backdrop.px.iter_mut().enumerate() { - let s = src.px[i]; - if s[3] <= 0.0 { - continue; - } - *b = psblend::composite_normal_rgb(*b, s, opacity); - } - return; + return psblend::composite_normal_rgb_span(dst, src, opacity); } - let rect = backdrop.rect; - let w = rect.width() as i32; - for (i, b) in backdrop.px.iter_mut().enumerate() { - let mut s = src.px[i]; + for (x, (b, s)) in (x0..).zip(dst.iter_mut().zip(src)) { + let mut s = *s; if s[3] <= 0.0 { continue; } - let mut mode = mode; if mode == BlendMode::Dissolve { - let x = rect.x0 + (i as i32 % w); - let y = rect.y0 + (i as i32 / w); s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 }; - mode = BlendMode::Normal; - *b = blend::composite(mode, *b, s, 1.0); + *b = blend::composite(BlendMode::Normal, *b, s, 1.0); continue; } *b = blend::composite_gamma(mode, *b, s, opacity, gamma); } } +#[cfg(test)] +mod soft_shape_tests; #[cfg(test)] mod stroke_tests; #[cfg(test)] diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index 8323e1b939..7335be90d0 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -171,17 +171,8 @@ fn key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. -/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector -/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's -/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). -pub fn feather_sigma(px: f32) -> f32 { - if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } -} - -const FEATHER_SIGMA: f32 = 1.0; -/// Photoshop's feather tops out at 1000 px. -const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA; +pub use photocraft_vector::feather::feather_sigma; +use photocraft_vector::feather::gaussian; /// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]). pub fn has_feather(layer: &Layer) -> bool { @@ -189,166 +180,6 @@ pub fn has_feather(layer: &Layer) -> bool { || layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0) } -/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just -/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. -#[derive(Clone, Copy, Debug, PartialEq)] -struct BoxPass { - r: usize, - end: f32, -} - -impl BoxPass { - /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et - /// al. 2011), or `None` when there is nothing to blur. - fn for_sigma(sigma: f32) -> Option { - if !sigma.is_finite() || sigma <= 0.0 { - return None; - } - // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. - let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; - // The largest whole radius whose box variance r(r+1)/3 is at most v. - let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; - let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; - while r > 0 && var(r) > v { - r -= 1; - } - while var(r + 1) <= v { - r += 1; - } - let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); - let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); - Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) - } -} - -/// Apply one of the three box passes to a single line. `scratch` preserves the input while -/// the sliding sum writes the output, matching the previous sequential implementation exactly. -fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { - if line.is_empty() { - return; - } - let Ok(radius) = isize::try_from(pass.r) else { return }; - scratch.copy_from_slice(line); - let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; - let mut acc: f32 = (-radius..=radius).map(get).sum(); - let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; - // The sample leaving the window is the next one's low end weight. - let mut below = get(-radius - 1); - for (i, dst) in line.iter_mut().enumerate() { - let i = i as isize; - let (above, leaving) = (get(i + radius + 1), get(i - radius)); - *dst = (acc + pass.end * (below + above)) / n; - acc += above - leaving; - below = leaving; - } -} - -/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used -/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon -/// work here can deadlock when every worker waits on the same OnceLock (#276). -fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { - let mut scratch = vec![0.0; w]; - for row in v.chunks_mut(w) { - for _ in 0..3 { - blur_line(row, &mut scratch, pass); - } - } -} - -fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { - if w == 0 || v.is_empty() { - return; - } - let h = v.len() / w; - let threads = if cfg!(target_arch = "wasm32") || v.len() < 1 << 16 { - 1 - } else { - std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) - }; - if threads == 1 { - blur_rows_chunk(v, w, pass); - return; - } - let rows_per_worker = h.div_ceil(threads); - let chunk_len = rows_per_worker.saturating_mul(w); - std::thread::scope(|scope| { - for chunk in v.chunks_mut(chunk_len) { - scope.spawn(move || blur_rows_chunk(chunk, w, pass)); - } - }); -} - -/// Cache-friendly blocked transpose. It makes the vertical blur contiguous without a Rayon job -/// or unsafe disjoint-column access; the temporary is released before the next mask is built. -fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { - const BLOCK: usize = 32; - for by in (0..h).step_by(BLOCK) { - for bx in (0..w).step_by(BLOCK) { - for y in by..(by + BLOCK).min(h) { - for x in bx..(bx + BLOCK).min(w) { - out[x * h + y] = v[y * w + x]; - } - } - } - } -} - -/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is -/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into -/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like -/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels -/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes -/// are processed in parallel on bounded OS threads, preserving each line's floating-point -/// operation order. -fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { - if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { - return; - } - let Some(pass) = BoxPass::for_sigma(sigma) else { return }; - // Reserve before modifying the input so allocation failure can use the sequential fallback - // without leaving a half-blurred mask. - let mut transposed = Vec::new(); - if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, pass); - return; - } - transposed.resize(v.len(), 0.0); - blur_rows(v, w, pass); - transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, pass); - transpose_into(&transposed, v, h, w); -} - -/// Allocation-light reference path used if parallel transpose storage cannot be reserved. -fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { - let mut line = Vec::new(); - let mut scratch = Vec::new(); - let len = w.max(h); - if line.try_reserve_exact(len).is_err() || scratch.try_reserve_exact(len).is_err() { - return; - } - line.resize(len, 0.0); - scratch.resize(len, 0.0); - for axis in [false, true] { - let (lines, line_len) = if axis { (w, h) } else { (h, w) }; - for k in 0..lines { - for i in 0..line_len { - line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; - } - for _ in 0..3 { - blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); - } - for i in 0..line_len { - if axis { - v[i * w + k] = line[i]; - } else { - v[k * w + i] = line[i]; - } - } - } - } -} - /// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather, /// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or /// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel @@ -372,29 +203,14 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { let d = m.surface.default_pixel().first().copied().unwrap_or(1.0); 1.0 - m.density * (1.0 - d) }); - let grow = |r: Rect, sigma: f32| { - let m = (sigma * 3.0).ceil() as i32 + 2; - if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) } - }; // Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. - let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow( - Rect::new( - (x0.floor() as i32).saturating_sub(2), - (y0.floor() as i32).saturating_sub(2), - (x1.ceil() as i32).saturating_add(2), - (y1.ceil() as i32).saturating_add(2), - ), - sv, - ), - None => Rect::EMPTY, - }; + let mut area = vm.map_or(Rect::EMPTY, |vm| photocraft_vector::feather::path_area(&vm.path, sv)); if let Some(m) = pixel && v_out > 0.0 { - let b = grow(m.surface.content_bounds(), sp); + let b = photocraft_vector::feather::grow(m.surface.content_bounds(), sp); area = if area.is_empty() { b } else if b.is_empty() { @@ -405,7 +221,14 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { } let area = area.intersect(&canvas); let mut s = Surface::with_default(FORMAT, &[p_def * v_out]); - if !area.is_empty() { + // Bound the transient dense working set: `area` is derived from sparse mask geometry grown + // by feather, so two distant changed pixels could span a canvas-sized rectangle. Building + // f32 planes over it (coverage, pixel-mask values, blur scratch) could be gigabytes during + // a one-pixel render. Above the budget, fall back to the constant default mask rather than + // allocating; feather detail is lost only for pathological geometry. + const MAX_MASK_WORKING_BYTES: u64 = 512 << 20; + let working = (area.width() as u64).saturating_mul(area.height() as u64).saturating_mul(std::mem::size_of::() as u64).saturating_mul(3); + if !area.is_empty() && working <= MAX_MASK_WORKING_BYTES { let (w, h) = (area.width() as usize, area.height() as usize); // A slot initializer must not enter Rayon: another tile requesting the // same mask could be stolen onto its stack and wait for its own build. @@ -729,79 +552,6 @@ mod tests { } } - /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole - /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). - #[test] - fn feather_blur_has_the_exact_variance() { - for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { - let (w, c) = (801usize, 400usize); - let mut v = vec![0.0f32; w]; - v[c] = 1.0; - gaussian(&mut v, w, 1, sigma); - // Within the kernel's reach (the running sums leave ~1e-8 behind further out). - let reach = (8.0 * sigma) as usize + 4; - let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); - assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); - assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); - } - assert_eq!(BoxPass::for_sigma(0.0), None); - assert_eq!(BoxPass::for_sigma(f32::NAN), None); - assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); - } - - /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools - /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it - /// within 1.5 levels, where whole-pixel boxes were 4 off. - #[test] - fn a_feathered_edge_is_a_gaussian_step() { - // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). - let erf = |x: f64| { - let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); - let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); - y.copysign(x) - }; - let (w, edge, sigma) = (240usize, 120usize, 6.0f32); - let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); - gaussian(&mut v, w, 1, sigma); - for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { - let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); - let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); - // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). - assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); - } - } - - #[test] - fn parallel_gaussian_matches_the_sequential_reference() { - let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); - let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = parallel.clone(); - let pass = BoxPass::for_sigma(sigma).unwrap(); - gaussian_sequential(&mut sequential, w, h, pass); - gaussian(&mut parallel, w, h, sigma); - assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); - } - - #[test] - #[ignore = "manual 50 MP sequential-vs-parallel mask blur measurement"] - fn benchmark_50mp_mask_blur() { - use std::time::Instant; - - let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); - let pass = BoxPass::for_sigma(sigma).unwrap(); - let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = source.clone(); - let start = Instant::now(); - gaussian_sequential(&mut sequential, w, h, pass); - let sequential_time = start.elapsed(); - let mut parallel = source; - let start = Instant::now(); - gaussian(&mut parallel, w, h, sigma); - let parallel_time = start.elapsed(); - assert_eq!(parallel, sequential); - eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); - } - #[test] fn cache_budget_holds_five_50mp_u8_input_u16_result_masks() { // Account for whole 256×256 tiles, as entry_bytes does, rather than just logical pixels. diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 88c0b2f359..4eedc88619 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -5,6 +5,7 @@ //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is //! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document //! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. +//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. use photocraft_doc::Document; use photocraft_geom::Rect; @@ -16,8 +17,10 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); for ch in &doc.channels { + let alpha = ch.spot.is_none(); let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - if ch.surface.content_bounds().intersect(&rect).is_empty() { + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { continue; } let c = ink.to_rgb(); @@ -25,7 +28,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let n = ch.surface.channels(); ch.surface.read_region_into(rect, &mut vals); for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = v[0].clamp(0.0, 1.0); + let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); if v <= 0.0 { continue; } @@ -66,14 +69,16 @@ mod tests { assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); - // An alpha-type channel prints as a black ink. + // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is + // unselected, as Photoshop shows it. let mut k = Surface::new(fmt); - k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]); + k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); let r = crate::flatten(&doc); // 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step. assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]); assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); + assert_eq!(r.px[3], [0.0, 0.0, 0.0, 1.0]); // Other modes have no inks. doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs index 0db59499de..771240a46b 100644 --- a/crates/compose/src/native_lab.rs +++ b/crates/compose/src/native_lab.rs @@ -89,6 +89,9 @@ fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: /// A Lab document's visible layers, prepared for compositing on their stored L, a and b. pub struct LabStack<'a> { items: Vec>, + /// Steps per unit of the document's samples (`None` at 32 bits): adjustment results are + /// whole samples, as Photoshop stores them. + quantum: Option, } impl<'a> LabStack<'a> { @@ -101,7 +104,7 @@ impl<'a> LabStack<'a> { } let mut items = Vec::new(); collect(&doc.layers, doc.depth, true, &mut items, 0)?; - Some(LabStack { items }) + Some(LabStack { items, quantum: crate::adjustment_quantum(doc.depth) }) } /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). @@ -161,6 +164,9 @@ impl<'a> LabStack<'a> { op(&mut lab); for (c, v) in lab.iter().enumerate() { d[c] += (v - d[c]) * mix; + if let Some(q) = self.quantum { + d[c] = (d[c] * q + 0.5).floor() / q; + } } } } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index d107ecf4e7..b66ab0ad1e 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -12,6 +12,10 @@ use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; const MAX_PREPARED_PATTERNS: usize = 64; +/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this +/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the +/// cap it is skipped instead. Independent of the cache budget so small patterns still convert. +const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; type PreparedSlot = Arc>>>; struct PreparedState { @@ -78,6 +82,13 @@ impl<'a> PreparedPatterns<'a> { // Run on the requesting worker to preserve its active CMYK profile. slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone() } + // Uncached path: a pattern whose full conversion would exceed an absolute cap is + // not materialized at all. Otherwise every output tile would re-expand the entire + // sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and + // memory even though cache admission already refused to retain it. The cap is + // independent of the (possibly zero) cache budget, so small patterns still convert + // on demand when nothing is cached. + None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None, None => Tile::new_in(p, self.mode).map(Arc::new), } } diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index 4cff6f3082..48c32b5e43 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -193,6 +193,7 @@ pub fn composite_fill(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opa /// Straight-alpha source over straight-alpha backdrop (W3C/PDF general /// formula) using [`blend_rgb`]. +#[inline] pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { let ab = backdrop[3]; let as_ = source[3] * opacity; @@ -238,6 +239,64 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity normal_rgb(backdrop, source, as_, ao) } +/// [`composite_normal_rgb`] over a row, with the same bits, in blocks the compiler vectorizes (see +/// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel. +pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) { + let n = dst.len().min(src.len()); + let (dc, dt) = dst[..n].as_chunks_mut::(); + let (sc, st) = src[..n].as_chunks::(); + for (b, s) in dc.iter_mut().zip(sc) { + normal_lanes(b, s, opacity); + } + for (b, s) in dt.iter_mut().zip(st) { + // Not `s[3] > 0`: a NaN alpha is blended (into NaN), as the scalar loop does. + if s[3] <= 0.0 { + continue; + } + *b = composite_normal_rgb(*b, *s, opacity); + } +} + +/// Pixels per block of [`normal_lanes`]: two 128-bit vectors (or one 256-bit) per channel. +const LANES: usize = 8; + +/// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels, +/// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a +/// select per pixel between the backdrop, zero and the mix. Same operations in the same order +/// (Rust never fuses them into multiply-adds) and the same tests: the same bits, except that a +/// NaN's sign and payload may differ (IEEE 754 doesn't fix which operand's NaN is passed on). +#[inline] +#[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep +fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) { + let lane = |p: &[[f32; 4]; LANES], c: usize| -> [f32; LANES] { std::array::from_fn(|i| p[i][c]) }; + let (b0, b1, b2, ab) = (lane(b, 0), lane(b, 1), lane(b, 2), lane(b, 3)); + let (s0, s1, s2, s3) = (lane(s, 0), lane(s, 1), lane(s, 2), lane(s, 3)); + let mut as_ = [0.0f32; LANES]; + let mut ao = [0.0f32; LANES]; + let mut kb = [0.0f32; LANES]; + let mut inv = [0.0f32; LANES]; + for i in 0..LANES { + as_[i] = s3[i] * opacity; + ao[i] = as_[i] + ab[i] * (1.0 - as_[i]); + kb[i] = ab[i] * (1.0 - as_[i]); + inv[i] = 1.0 / ao[i]; + } + for i in 0..LANES { + let mix = [(kb[i] * b0[i] + as_[i] * s0[i]) * inv[i], (kb[i] * b1[i] + as_[i] * s1[i]) * inv[i], (kb[i] * b2[i] + as_[i] * s2[i]) * inv[i], ao[i]]; + // The scalar path's tests: skipped where the source is empty (`s[3] <= 0`) or adds + // nothing (`as_ <= 0`), zero where nothing is left (`ao <= 0`). + let keep = (s3[i] <= 0.0) | (as_[i] <= 0.0); + let gone = ao[i] <= 0.0; + b[i] = if keep { + b[i] + } else if gone { + [0.0; 4] + } else { + mix + }; + } +} + #[inline] fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] { // B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha. @@ -293,6 +352,7 @@ pub fn text_decode(v: f32, gamma: f32) -> f32 { /// [`composite`] with the coverage mix done in the text blending space of `gamma` /// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off). +#[inline] pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] { if gamma == 1.0 { return composite(mode, backdrop, source, opacity); diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 9f0143f889..09d404669b 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -53,6 +53,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); depth.hash(&mut h); format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h); + (sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h); if let Some(c) = &sh.cache { format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h); for (k, t) in c.tiles() { @@ -163,6 +164,7 @@ mod tests { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } diff --git a/crates/compose/src/soft_shape_tests.rs b/crates/compose/src/soft_shape_tests.rs new file mode 100644 index 0000000000..222b3f3e9a --- /dev/null +++ b/crates/compose/src/soft_shape_tests.rs @@ -0,0 +1,199 @@ +//! A shape layer's own Density and Feather (Properties › Masks: its path is its vector mask), +//! against Photoshop's measured pixels and against a fill layer with an equal vector mask. + +use super::*; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath, VectorMask}; +use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_geom::Size; + +/// Photoshop: new 200×200 white RGB document, a black 100×100 rectangle shape at 50..150 with a +/// 10 px vector-mask feather, row y = 100 at x = 30, 33, …, 72 (8-bit grey levels). +const PS_FEATHER_10: [u8; 15] = [249, 242, 232, 216, 195, 170, 142, 113, 85, 60, 39, 23, 13, 6, 2]; + +fn square() -> Path { + Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)])]) +} + +fn doc(depth: SampleType) -> Document { + Document::with_background("t", Size::new(200, 200), ColorMode::Rgb, depth, Color::WHITE) +} + +/// The document with a black square shape layer of `density` (0..1) and `feather` (px). +fn shape_doc(depth: SampleType, density: f32, feather: f32, stroke: Option) -> Document { + let mut d = doc(depth); + let mut sh = ShapeLayer { path: square(), fill: Some(Fill::Solid(Color::BLACK)), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Rectangle 1", LayerContent::Shape(sh))); + d +} + +/// The same appearance built as Photoshop stores it: a black fill layer with a vector mask. +fn fill_doc(depth: SampleType, density: f32, feather: f32) -> Document { + let mut d = doc(depth); + let mut l = Layer::new("Color Fill 1", LayerContent::Fill(Fill::Solid(Color::BLACK))); + l.vector_mask = Some(VectorMask { density, feather, ..VectorMask::new(square()) }); + d.layers.push(l); + d +} + +/// Row `y` of the composite as 8-bit grey levels. +fn row(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 255.0).round() as i32).collect() +} + +/// Row `y` of the composite in 16-bit levels. +fn row16(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 65535.0).round() as i32).collect() +} + +fn max_diff(a: &[i32], b: &[i32]) -> i32 { + a.iter().zip(b).map(|(x, y)| (x - y).abs()).max().unwrap_or(0) +} + +/// A 10 px feather is a symmetric ramp centred on the path, as Photoshop draws it. +#[test] +fn feather_matches_photoshops_ramp() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 1.0, 10.0, None), 100); + let ours: Vec = (0..15).map(|i| r[30 + 3 * i]).collect(); + let dev = ours.iter().zip(PS_FEATHER_10).map(|(a, b)| (a - i32::from(b)).abs()).max().unwrap_or(0); + // Same Gaussian (sigma = feather) as a vector mask: within 1 level of Photoshop (as + // scorecard TOOL-214-12 claims; measured max deviation 1 at every depth). + assert!(dev <= 1, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})"); + eprintln!("{depth:?}: ours {ours:?}, Photoshop {PS_FEATHER_10:?}, max deviation {dev}"); + // The path runs between pixels 49 and 50: their mean is half way. + assert!((r[49] + r[50] - 255).abs() <= 2, "{depth:?}: half way on the path: {} {}", r[49], r[50]); + assert_eq!(r[100], 0, "{depth:?}: the centre stays solid"); + // Symmetric about the square's centre. + assert!((0..200).all(|x| (r[x] - r[199 - x]).abs() <= 1), "{depth:?}: {r:?}"); + } +} + +/// Density 50 %: the fill shows at half strength beyond the path, full inside, hard edge. +#[test] +fn density_shows_the_fill_beyond_the_path() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 0.5, 0.0, None), 100); + for x in [0, 20, 49, 150, 199] { + assert!((127..=128).contains(&r[x]), "{depth:?}: x={x} outside the path: {}", r[x]); + } + for x in [50, 100, 149] { + assert_eq!(r[x], 0, "{depth:?}: x={x} inside the path"); + } + let top = row(&shape_doc(depth, 0.5, 0.0, None), 0); + assert!(top.iter().all(|v| (127..=128).contains(v)), "{depth:?}: everywhere beyond the path"); + } +} + +/// A shape and a fill layer with an equal vector mask render the same pixels. (Density 50 % puts +/// white × 0.5 = 127.5 exactly on a rounding tie, which the shape's stored pixels and the fill's +/// mask break differently: 127 and 128, both what Photoshop's "127/128" allows.) +#[test] +fn soft_shape_matches_an_equal_vector_mask() { + for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0)] { + for depth in [SampleType::U8, SampleType::U16] { + let (s, f) = (shape_doc(depth, density, feather, None), fill_doc(depth, density, feather)); + for y in [0, 45, 50, 100, 151, 199] { + // An 8-bit shape keeps its rendering in 8-bit pixels (the fill layer's mask is + // 16-bit), which can round its alpha one level apart, as at any anti-aliased edge. + let d8 = max_diff(&row(&s, y), &row(&f, y)); + assert!(d8 <= i32::from(depth == SampleType::U8), "{depth:?} density {density} feather {feather}, row {y}: {d8} levels"); + // The compositor keeps masks at 16 bits (an 8-bit shape's pixels are 8-bit). + if depth == SampleType::U16 { + let d16 = max_diff(&row16(&s, y), &row16(&f, y)); + assert!(d16 <= 2, "{depth:?} density {density} feather {feather}, row {y}: {d16} 16-bit levels"); + } + } + } + } +} + +/// Density and feather soften the fill only: the vector stroke keeps its crisp edge. +#[test] +fn soft_shape_keeps_its_stroke_crisp() { + let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() }; + let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke)); + let px = render(&d, Rect::new(0, 100, 200, 101)).px; + // Centred 4 px stroke over x = 48..52: opaque red. + for x in [48, 49, 50, 51] { + let p = px[x]; + assert!(p[0] > 0.99 && p[1] < 0.01 && p[2] < 0.01, "x={x}: {p:?}"); + } + // Beyond the stroke the feathered fill continues: at 10 px out, 50 % density plus half of + // the feather's 16 % ≈ 58 % black. + assert!((px[40][1] - 0.42).abs() < 0.03, "{:?}", px[40]); +} + +/// Defaults (density 100 %, no feather) leave a shape exactly as before. +#[test] +fn default_shape_is_unchanged() { + let d = shape_doc(SampleType::U8, 1.0, 0.0, None); + let r = row(&d, 100); + assert_eq!(r[49], 255); + assert_eq!(r[50], 0); + assert_eq!(r[149], 0); + assert_eq!(r[150], 255); + assert!(row(&d, 10).iter().all(|v| *v == 255)); +} + +/// File-provided values outside the range never panic and render as the nearest valid ones. +#[test] +fn hostile_values_render() { + for (density, feather) in [(f32::NAN, f32::NAN), (-1.0, -5.0), (7.0, 1.0e9), (f32::INFINITY, f32::NEG_INFINITY)] { + let d = shape_doc(SampleType::U8, density, feather, None); + let r = row(&d, 100); + assert_eq!(r.len(), 200, "{density} {feather}"); + } +} + +/// The layer's bounds reach as far as its soft fill. +#[test] +fn soft_shape_bounds_cover_the_fill() { + let canvas = Rect::new(0, 0, 200, 200); + let feathered = shape_doc(SampleType::U8, 1.0, 10.0, None); + let b = layer_bounds(&feathered.layers[1], canvas); + assert!(b.x0 <= 50 - 30 && b.x1 >= 150 + 30, "feather reach: {b:?}"); + let dense = shape_doc(SampleType::U8, 0.5, 0.0, None); + assert_eq!(layer_bounds(&dense.layers[1], canvas), canvas); + assert!(effect_outline(&dense.layers[1]).is_none(), "a soft fill's alpha is its outline"); +} + +/// A soft shape's frame is the hard shape's painted pixels, also where a subtracted subpath +/// leaves most of the rasterizers' bounds empty (#5846), at every depth. +#[test] +fn soft_shape_frame_skips_a_subtracted_margin() { + let canvas = Rect::new(0, 0, 200, 200); + let mut cut = Subpath::polygon(&[(40.0, 40.0), (160.0, 40.0), (160.0, 140.0), (40.0, 140.0)]); + cut.op = photocraft_doc::vector::PathOp::Subtract; + let path = Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)]), cut]); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let sh = ShapeLayer { path: path.clone(), fill: Some(Fill::Solid(Color::BLACK)), density: 0.5, feather: 4.0, ..ShapeLayer::default() }; + assert_eq!(soft_shape_frame(&sh, doc(depth).pixel_format(), canvas), Some(Rect::new(50, 140, 150, 150)), "{depth:?}"); + } +} + +/// The frame scan renders a few strips per edge, not one per empty row or column: a 2-px +/// painted spot in a 4000 × 3000 bounds takes a handful of renders and is found exactly. +#[test] +fn soft_shape_frame_scan_is_bounded() { + let spot = Rect::new(2500, 1700, 2502, 1702); + let mut calls = 0; + let got = trim_to_painted(Rect::new(0, 0, 4000, 3000), |strip| { + calls += 1; + strip.intersect(&spot) + }); + assert_eq!(got, Some(spot)); + assert!(calls <= 4 * 14, "{calls} renders"); + let mut calls = 0; + assert_eq!( + trim_to_painted(Rect::new(0, 0, 4000, 3000), |_| { + calls += 1; + Rect::EMPTY + }), + None + ); + assert!(calls <= 14, "an empty shape stops after the top edge: {calls} renders"); + assert_eq!(trim_to_painted(Rect::EMPTY, |r| r), None); + assert_eq!(trim_to_painted(Rect::new(3, 4, 9, 7), |r| r), Some(Rect::new(3, 4, 9, 7)), "a full rect keeps its edges"); +} diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index ab265fa208..416c062442 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -21,7 +21,7 @@ fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] { } fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect { - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint }) + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint, overprint: false }) } fn blue() -> FxPaint { @@ -31,7 +31,7 @@ fn blue() -> FxPaint { /// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels. fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option) -> Layer { let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40))); Layer::new("shape", LayerContent::Shape(sh)) } @@ -45,6 +45,7 @@ fn gradient_shape(width: u32, height: u32) -> ShapeLayer { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } @@ -234,7 +235,15 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() { let mut cache = photocraft_raster::Surface::new(fmt); cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]); cache.fill_rect(Rect::new(11, 11, 29, 29), &[fill, fill, fill, 1.0]); - let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None }; + let sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), + stroke: Some(vs), + live: None, + cache: Some(cache), + psd_raw: None, + ..ShapeLayer::default() + }; let mut l = Layer::new("shape", LayerContent::Shape(sh)); // Any layer effect sends the shape through the fill/stroke split. l.effects.items = vec![Effect::default_drop_shadow()]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..8f111269a8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -14,6 +14,93 @@ fn doc_white(w: u32, h: u32) -> Document { Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE) } +#[test] +fn prepared_tones_match_fresh_tables_and_stay_bounded() { + use photocraft_doc::adjust::ToneSpace; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] { + for (mut adj, edited) in [ + (Adjustment::identity_levels(), false), + (Adjustment::identity_levels(), true), + (Adjustment::identity_curves(), false), + (Adjustment::identity_curves(), true), + ] { + match &mut adj { + Adjustment::Levels { master, space: s, .. } => { + if edited { + master.gamma = 1.4; + } + *s = space; + } + Adjustment::Curves { master, space: s, .. } => { + if edited { + master[1].output = 0.8; + } + *s = space; + } + _ => {} + } + let mut doc = Document::new("tones", Size::new(3, 1), ColorMode::Rgb, depth); + doc.layers.push(Layer::group("group", vec![Layer::new("tone", LayerContent::Adjustment(adj.clone()))])); + let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&doc, &patterns); + let cached_adj = cx.tone_luts[0].0; + let input = Buffer { rect: doc.bounds(), px: vec![[0.2, 0.4, 0.7, 0.6], [2.0, -0.2, 0.5, 0.5], [2.0, -0.2, 0.5, 0.0]] }; + let mut expected = input.clone(); + adjust::apply_depth(&adj, &mut expected, cx.transfer, Some(depth)); + for a in [cached_adj, cached_adj, &adj] { + let mut actual = input.clone(); + cx.apply_adjustment(a, &mut actual); + assert_eq!(actual.px, expected.px); + } + let direct = depth == SampleType::F32 && matches!(adj, Adjustment::Levels { space: ToneSpace::Rgb, .. }); + let identity = adjust::float_tone_identity(&adj, Some(depth)) == [true; 3]; + assert_eq!(cx.tone_luts[0].1.get().is_none(), direct || identity); + } + } + } + let mut layers: Vec<_> = (0..20).map(|_| Layer::new("tone", LayerContent::Adjustment(Adjustment::identity_levels()))).collect(); + layers[0].visible = false; + let tables = prepare_tones(&layers, 0); + assert_eq!(tables.len(), 16); + assert!(tables.iter().all(|(_, cell)| cell.get().is_none())); + assert!(matches!(&layers[1].content, LayerContent::Adjustment(a) if std::ptr::eq(tables[0].0, a))); + assert!(prepare_tones(&[Layer::group("deep", layers)], PREPARE_DEPTH).is_empty()); +} + +#[test] +fn prepared_adjustments_keep_document_channel_semantics() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = Document::new("channels", Size::new(1, 1), mode, depth); + let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&doc, &patterns); + for adj in [Adjustment::Invert, Adjustment::BrightnessContrast { brightness: 20.0, contrast: 30.0, legacy: false }] { + let mut actual = Buffer::filled(doc.bounds(), [0.2, 0.4, 0.7, 0.6]); + let mut expected = actual.clone(); + adjust::apply_in(&adj, &mut expected, cx.transfer, Some(depth), mode); + cx.apply_adjustment(&adj, &mut actual); + assert_eq!(actual.px, expected.px); + } + } + } +} + +#[test] +fn identity_float_curves_match_previous_mapping_bits() { + let adj = Adjustment::identity_curves(); + let input = Buffer { + rect: Rect::new(0, 0, 4, 1), + px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]], + }; + let mut previous = input.clone(); + adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]); + let mut actual = input.clone(); + adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); + assert_buffer_bits(&actual, &previous); + assert_buffer_bits(&actual, &input); +} + fn solid_layer(name: &str, rect: Rect, rgba: [f32; 4]) -> Layer { let mut l = Layer::raster(name, PixelFormat::RGBA8); l.surface_mut().unwrap().fill_rect(rect, &rgba); @@ -80,6 +167,108 @@ fn normal_rgb_kernel_preserves_scalar_bits() { } } +/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover +/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals +/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN. +#[test] +fn normal_rgb_span_preserves_scalar_bits() { + let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30]; + let mut seed = 0x9e37_79b9u32; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + let mut value = || { + let r = next(); + if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 } + }; + for len in 0..41 { + for _ in 0..40 { + let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let opacity = value(); + let mut want = backdrop.clone(); + for (b, s) in want.iter_mut().zip(&source) { + if s[3] <= 0.0 { + continue; + } + *b = original_normal_rgb(*b, *s, opacity); + } + let mut got = backdrop; + psblend::composite_normal_rgb_span(&mut got, &source, opacity); + // IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the + // vector units differ in its sign), so any NaN counts as the same NaN. + let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() }); + for (i, (g, w)) in got.iter().zip(&want).enumerate() { + assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); + } + } + } +} + +/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy +/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes +/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the +/// layer's pixels. +#[test] +fn pixel_layer_rows_match_tile_copy() { + let rect = Rect::new(-3, 2, 37, 21); + let modes = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Dissolve, + BlendMode::Overlay, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::VividLight, + BlendMode::Difference, + BlendMode::Hue, + ]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut l = Layer::raster("p", fmt); + for y in 0..20 { + for x in 0..30 { + let t = (x * 7 + y * 13) as f32 / 400.0; + let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4]; + l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a])); + } + } + let mut sharp = LayerMask::reveal_all(); + sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]); + let mut feathered = sharp.clone(); + feathered.feather = 2.5; + for mask in [None, Some(sharp), Some(feathered)] { + l.mask = mask; + for mode in modes { + for fill in [1.0, 0.4] { + l.blend = mode; + l.opacity = 0.8; + l.fill_opacity = fill; + let mut doc = doc_white(40, 30); + doc.layers.push(l.clone()); + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(&doc, &patterns); + let layer = &doc.layers[1]; + let backdrop = Buffer { + rect, + px: (0..rect.width() * rect.height()) + .map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]]) + .collect(), + }; + let mut want = backdrop.clone(); + let content = render_content(layer, rect, &cx).unwrap(); + blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); + let mut got = backdrop; + composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx); + assert_buffer_bits(&got, &want); + } + } + } + } +} + fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) { let rect = Rect::new(-3, -2, 5, 0); let mut bottom = Surface::new(fmt); @@ -316,6 +505,65 @@ fn clipping_mask_restricts_to_base_alpha() { assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden"); } +/// #3052 / #4921: clipped Dissolve must apply layer Opacity × Fill as binary coverage, +/// restricted to the clipping base's shape. +#[test] +fn clipped_dissolve_opacity_fill_is_binary_and_preserves_base_shape() { + let mut d = doc_white(40, 12); + d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0])); + let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = 0.6; + clip.fill_opacity = 0.5; + d.layers.push(clip); + let mut red = 0; + let mut blue = 0; + for y in 0..12 { + for x in 0..20 { + let p = px(&d, x, y); + if close4(p, [1.0, 0.0, 0.0, 1.0]) { + red += 1; + } else { + assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})"); + blue += 1; + } + } + assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible"); + } + assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}"); + assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}"); +} + +/// Dissolve noise uses document coordinates, not the region origin; checked across +/// negative coordinates, tile boundaries, and all supported RGB sample depths. +#[test] +fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("coverage", Size::new(32, 18), ColorMode::Rgb, fmt.sample); + let bounds = Rect::new(-3, -2, 32, 18); + let mut base = Layer::raster("base", fmt); + base.surface_mut().unwrap().fill_rect(bounds, &[0.0, 0.0, 1.0, 0.5]); + d.layers.push(base); + let mut clip = Layer::raster("clip", fmt); + clip.surface_mut().unwrap().fill_rect(bounds, &[1.0, 0.0, 0.0, 0.75]); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = 0.6; + clip.fill_opacity = 0.5; + d.layers.push(clip); + let whole = render(&d, bounds); + for region in [Rect::new(-3, -2, 5, 4), Rect::new(5, 4, 17, 11), Rect::new(17, 11, 32, 18)] { + let part = render(&d, region); + for y in region.y0..region.y1 { + for x in region.x0..region.x1 { + assert!(close4(part.get(x, y), whole.get(x, y)), "{fmt:?}: region mismatch at ({x},{y})"); + } + } + } + } +} + #[test] fn hidden_base_hides_clipping_group() { let mut d = doc_white(4, 4); @@ -448,6 +696,115 @@ fn clipped_adjustment_only_affects_base() { assert!(close4(px(&d, 3, 0), [1.0; 4]), "background untouched (white)"); } +/// #6535: showing or hiding a clipped adjustment changes only its base's pixels, so its change +/// bounds are the base's; it still reaches everywhere without a base, or clipped to an +/// adjustment, or to a pass-through group whose adjustment it isolates. +#[test] +fn clipped_adjustment_changes_only_its_base() { + let canvas = Rect::new(0, 0, 8, 6); + let base_rect = Rect::new(2, 1, 5, 4); + let mut d = doc_white(8, 6); + d.layers.push(solid_layer("base", base_rect, [0.2, 0.4, 0.6, 1.0])); + let mut over = solid_layer("over", Rect::new(3, 2, 4, 3), [0.9, 0.1, 0.1, 1.0]); + over.clipped = true; + d.layers.push(over); + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.clipped = true; + d.layers.push(adj); + assert_eq!(change_bounds(&d.layers[3], canvas), None); + assert_eq!(change_bounds_in(&d.layers, 3, None, canvas), Some(base_rect)); + // Every pixel the toggle changes lies inside those bounds. + let shown = render(&d, canvas); + d.layers[3].visible = false; + let hidden = render(&d, canvas); + let mut changed = Rect::EMPTY; + for (i, (a, b)) in shown.px.iter().zip(&hidden.px).enumerate() { + if !close4(*a, *b) { + let (x, y) = ((i % 8) as i32, (i / 8) as i32); + assert!(base_rect.contains(x, y), "pixel {x},{y} changed outside the base"); + let p = Rect::from_xywh(x, y, 1, 1); + changed = if changed.is_empty() { p } else { changed.union(&p) }; + } + } + assert_eq!(changed, base_rect); + // Other layers keep their own bounds. + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(3, 2, 4, 3))); + assert_eq!(change_bounds_in(&d.layers, 9, None, canvas), None); + + // Unclipped, or with no base beneath it, it reaches everything. + let mut free = d.layers.clone(); + free[3].clipped = false; + assert_eq!(change_bounds_in(&free, 3, None, canvas), None); + let orphan = vec![d.layers[3].clone()]; + assert_eq!(change_bounds_in(&orphan, 0, None, canvas), None); + // Clipped layers on an adjustment composite atop the adjusted backdrop. + let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()]; + assert_eq!(change_bounds_in(&on_adj, 2, None, canvas), None); + // A pass-through group holding an adjustment is isolated only while it has visible clipped + // layers, so they change whether its adjustment reaches the layers beneath. + let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); + g.blend = BlendMode::PassThrough; + let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()]; + assert_eq!(change_bounds_in(&on_group, 2, None, canvas), None); +} + +/// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites +/// as an ordinary adjustment, so hiding the group or the layer clipped to it (which ends its +/// isolation) changes the whole canvas, not just the group's pixels. +#[test] +fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() { + let canvas = Rect::new(0, 0, 8, 6); + let mut d = doc_white(8, 6); + let mut orphan = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + orphan.clipped = true; + let mut g = Layer::group("G", vec![orphan, solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0])]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let mut x = Layer::new("X", LayerContent::Adjustment(Adjustment::Invert)); + x.clipped = true; + d.layers.push(x); + let shown = px(&d, 0, 0); + d.layers[2].visible = false; + assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group"); + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), None); + assert_eq!(change_bounds(&d.layers[1], canvas), None); + // With a base beneath it the clipped adjustment stays inside the group. + let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") }; + g.children.swap(0, 1); + assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(2, 1, 4, 3))); +} + +/// A pass-through group that is a clip base renders isolated while it holds a visible +/// adjustment, so hiding a clipped adjustment inside it changes the group's other children too: +/// its damage is the group's, not just its base's. +#[test] +fn clipped_adjustment_in_pass_through_clip_base_reaches_its_group() { + let canvas = Rect::new(0, 0, 10, 8); + let mut d = doc_white(10, 8); + d.layers.push(solid_layer("bg", canvas, [0.5, 0.5, 0.5, 1.0])); + let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + a.clipped = true; + let mut q = solid_layer("Q", Rect::new(6, 4, 8, 6), [0.5, 0.5, 0.5, 1.0]); + q.blend = BlendMode::Multiply; + let mut g = Layer::group("G", vec![solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0]), a, q]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let mut x = solid_layer("X", Rect::new(2, 1, 3, 2), [1.0, 0.0, 0.0, 1.0]); + x.clipped = true; + d.layers.push(x); + let shown = px(&d, 7, 5); + let LayerContent::Group(g) = &mut d.layers[2].content else { panic!("group") }; + g.children[1].visible = false; + assert!(!close4(shown, px(&d, 7, 5)), "hiding A changes Q, outside A's base"); + let group = Rect::new(2, 1, 8, 6); + let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") }; + assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(group)); + // Not a clip base: the group stays pass-through either way, and A changes only its base. + d.layers.truncate(3); + let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") }; + assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(Rect::new(2, 1, 4, 3))); +} + #[test] fn threshold_posterize_levels_curves() { let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]); @@ -874,6 +1231,7 @@ fn stroke(size: f32, position: StrokePosition) -> Effect { size, position, paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, }) } @@ -1050,6 +1408,61 @@ fn outer_and_inner_glow_regions() { assert!(close4(px(&d, 5, 20), [1.0; 4])); } +// An effect in Dissolve mode (#6995) shows its colour fully or not at all at each pixel, so a soft +// glow becomes speckle thinning out with the glow instead of a smooth fade, and a half-opaque +// outside stroke is scattered dots. The speckle follows document coordinates: a tile renders the +// same pixels as the whole document. +#[test] +fn dissolve_effects_are_speckled_not_faded() { + let ring = |d: &Document| -> Vec<(i32, [f32; 4])> { + let mut v = Vec::new(); + for y in 2..38_i32 { + for x in 2..38_i32 { + if !(9..31).contains(&x) || !(9..31).contains(&y) { + v.push(((x - 20).abs().max((y - 20).abs()) - 10, px(d, x, y))); + } + } + } + v + }; + let soft = Glow { size: 8.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) }; + let faded = fx_doc(vec![Effect::OuterGlow(soft.clone())]); + assert!(ring(&faded).iter().any(|(_, p)| p[0] > 0.1 && p[0] < 0.9), "a Normal glow fades smoothly"); + let dissolved = Glow { common: FxCommon::new(BlendMode::Dissolve, 1.0), ..soft }; + let d = fx_doc(vec![Effect::OuterGlow(dissolved.clone())]); + let (mut near, mut far) = ((0, 0), (0, 0)); + for (dist, p) in ring(&d) { + let green = close4(p, [0.0, 1.0, 0.0, 1.0]); + assert!(green || close4(p, [1.0; 4]), "each pixel is the glow or the backdrop: {p:?} at distance {dist}"); + let n = if dist <= 3 { &mut near } else { &mut far }; + n.0 += usize::from(green); + n.1 += 1; + } + let share = |(g, n): (usize, usize)| g as f32 / n.max(1) as f32; + assert!(share(near) > 0.3 && share(far) < share(near) && far.0 > 0, "speckle thins out with the glow: {near:?} {far:?}"); + assert!(!crate::effects::maps_are_local(&d.layers[1]), "Dissolve speckles any coverage, so its maps are not tiled"); + let full = flatten(&d); + let tile = render(&d, Rect::new(4, 4, 21, 17)); + for y in 4..17 { + for x in 4..21 { + assert_eq!(full.get(x, y), tile.get(x, y), "({x},{y})"); + } + } + + let d = fx_doc(vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Dissolve, 0.5), + size: 4.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })]); + let band: Vec<[f32; 4]> = ring(&d).into_iter().filter(|(dist, _)| (0..4).contains(dist)).map(|(_, p)| p).collect(); + let blue = band.iter().filter(|p| close4(**p, [0.0, 0.0, 1.0, 1.0])).count(); + assert!(band.iter().all(|p| close4(*p, [0.0, 0.0, 1.0, 1.0]) || close4(*p, [1.0; 4])), "no half-blue pixel"); + let share = blue as f32 / band.len() as f32; + assert!((share - 0.5).abs() < 0.1, "about half the band at 50 % opacity: {share}"); +} + // A Precise inner glow from the centre is brightest in the middle and fades towards the edge, // like the Softer one; it used to paint nothing at all (#966). #[test] @@ -1082,6 +1495,34 @@ fn glow_gradient_follows_the_shape() { assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}"); } +// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the +// new pixels (#4053). Effects that don't depend on the frame repaint only what changed. +#[test] +fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { + let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { + let mut b = a.clone(); + let old = b.layers.last().unwrap().clone(); + let mut l = solid_layer("sq", rect, rgba); + (l.id, l.effects) = (old.id, old.effects); + *b.layers.last_mut().unwrap() = l; + b + }; + let a = fx_doc(vec![overlay.clone()]); + let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]); + let d = crate::relayout_damage(&a, &b); + assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}"); + // Same bounds, other pixels: the frame stays, the pixels' own damage is enough. + assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY); + // A Color Overlay doesn't depend on the frame. + let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]); + assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); + // Nor does a disabled overlay. + let mut off = a.clone(); + off.layers.last_mut().unwrap().effects.enabled = false; + assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); +} + #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; @@ -1122,6 +1563,7 @@ fn satin_and_bevel_stay_inside_shape() { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), @@ -1138,6 +1580,57 @@ fn satin_and_bevel_stay_inside_shape() { assert!(top[1] > bot[1], "{top:?} {bot:?}"); } +/// #7360: a gloss contour that starts high (Rolling Slope - Descending, as the reporter's PSD +/// uses) left a bevelled shape's flat face at the full Overlay highlight, (255, 40, 42) on a +/// (151, 22, 23) fill, where Photoshop leaves the face at the fill colour. The contour maps the +/// shading, so the flat face stays neutral and only the slopes are shaded. +#[test] +fn a_gloss_contour_leaves_the_flat_face_of_a_bevel_unlit() { + let fill = [151.0 / 255.0, 22.0 / 255.0, 23.0 / 255.0, 1.0]; + let rolling = photocraft_doc::Contour::Custom { + name: "Rolling Slope - Descending".into(), + points: [(0.0078, 1.0), (0.2078, 0.3922), (0.3804, 0.4196), (0.7020, 0.6), (0.9882, 0.0)] + .iter() + .map(|&(input, output)| CurvePoint { input, output }) + .collect(), + corners: vec![], + }; + let render = |gloss_contour: photocraft_doc::Contour| { + let mut d = doc_white(40, 40); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), fill); + l.effects.items = vec![Effect::BevelEmboss(photocraft_doc::Bevel { + enabled: true, + style: photocraft_doc::BevelStyle::InnerBevel, + technique: photocraft_doc::BevelTechnique::ChiselHard, + depth: 1.47, + up: false, + size: 3.0, + soften: 0.0, + angle: 90.0, + altitude: 30.0, + use_global_light: false, + gloss_contour, + gloss_anti_alias: false, + highlight: FxCommon::new(photocraft_color::BlendMode::Overlay, 1.0), + highlight_color: Color::WHITE, + shadow: FxCommon::new(photocraft_color::BlendMode::Overlay, 0.16), + shadow_color: Color::BLACK, + contour: None, + texture: None, + })]; + d.layers.push(l); + d + }; + let d = render(rolling); + for (x, y) in [(20, 20), (16, 22), (24, 16)] { + let p = px(&d, x, y); + assert!(close4(p, fill), "flat face ({x},{y}) keeps the fill: {:?}", p.map(|v| (v * 255.0).round())); + } + let linear = render(photocraft_doc::Contour::Linear); + assert!(close4(px(&linear, 20, 20), fill), "Linear leaves the flat face too"); + assert!(!close4(px(&d, 20, 11), fill), "the slopes are still shaded"); +} + #[test] fn clipped_layer_effects_are_clipped_to_base() { let mut d = doc_white(40, 40); @@ -1298,6 +1791,7 @@ fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), @@ -1448,6 +1942,126 @@ fn shapeless_effect_maps_cover_only_the_band_being_rendered() { ); } +/// [`shapeless_fx_doc`] with Transparency Shapes Layer back on everywhere: ordinary layers, whose +/// effect regions are their own bounds grown by the effects' reach. +fn ordinary_fx_doc(depth: SampleType) -> Document { + fn shaped(layers: &mut [Layer]) { + for l in layers { + l.advanced.transparency_shapes = true; + if let LayerContent::Group(g) = &mut l.content { + shaped(&mut g.children); + } + } + } + let mut d = shapeless_fx_doc(depth); + shaped(&mut d.layers); + d +} + +#[test] +fn effect_maps_over_the_cache_budget_are_built_over_the_render_rect() { + // #8330: an ordinary layer's maps cover its whole region, so on a large document a render's + // layers can need more than the effect cache holds; building one then evicts the next one + // needed, and every render, however small, builds them all again. Over the budget, local maps + // are built over the render rect (plus twice the reach), as a shapeless layer's are (#1909). + let d = ordinary_fx_doc(SampleType::U8); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + // A row through fx8 (pixels at y 552..570, an outside stroke and an inner glow). + let band = Rect::new(0, 561, 48, 562); + let mut cx = Ctx::for_doc(&d, &patterns); + assert!(!cx.fx_over_budget, "a small document fits"); + let l = &d.layers[9]; + let whole = effect_region(l, band, &cx); + cx.set_fx_clip(band); + assert_eq!(effect_region(l, band, &cx), whole, "under the budget: the whole region"); + cx.fx_over_budget = true; + let (part, clipped) = effect_region(l, band, &cx); + assert_eq!(part, whole.0.intersect(&band.inflate(2 * effects::margin(l)))); + assert!(clipped && part.height() < whole.0.height(), "{part:?} vs {:?}", whole.0); + let built = effect_maps(l, band, &cx).bytes(); + cx.fx_over_budget = false; + cx.set_fx_clip(Rect::new(0, 0, 1, 1)); + cx.set_fx_clip(band); + assert!(built < effect_maps(l, band, &cx).bytes()); + cx.fx_over_budget = true; + // Composited over more than the band, and maps that aren't local: the whole region. + assert_eq!(effect_region(l, Rect::new(0, 500, 48, 600), &cx), whole); + let noisy = &d.layers[2]; + assert!(!effects::maps_are_local(noisy)); + cx.set_fx_clip(Rect::new(0, 100, 48, 140)); + assert!(!effect_region(noisy, Rect::new(0, 100, 48, 140), &cx).1); +} + +#[test] +fn renders_with_maps_clipped_over_the_budget_match_the_whole_maps() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = ordinary_fx_doc(depth); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + purge_effect_cache(); + let whole = render(&d, d.bounds()); + let mut cx = Ctx::for_doc(&d, &patterns); + cx.fx_over_budget = true; + // Bands, edit-sized parts and single tiles, each from maps built over it alone. + purge_effect_cache(); + let bands = (0..10).map(|i| Rect::new(0, i * 64, 48, (i + 1) * 64)); + let parts = [Rect::new(3, 290, 41, 333), Rect::new(0, 0, 48, 20), Rect::new(20, 600, 48, 640), Rect::new(0, 296, 20, 312), Rect::new(10, 425, 40, 445)]; + for r in bands.chain(parts) { + let p = render_tiled_with(&d, r, RENDER_TILE, &cx); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + assert_eq!(p.get(x, y), whole.get(x, y), "{depth:?}: {r:?} differs at ({x},{y})"); + } + } + } + assert!(render_tiled_with(&d, d.bounds(), 16, &cx).px == whole.px, "{depth:?}: tiled render differs"); + } +} + +#[test] +fn the_effect_map_estimate_counts_the_maps_building_takes() { + // fx_over_budget compares the maps' bytes with the budget: too few and a document over it + // keeps thrashing, too many and one that fits loses its shared maps. Strokes build distance + // fields and overlays nothing beyond the shape (#8372 review), so count what the builder does. + let mut d = ordinary_fx_doc(SampleType::U8); + let overlay = Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }; + for fx in [vec![overlay], vec![stroke(2.0, StrokePosition::Outside), stroke(60.0, StrokePosition::Outside)], vec![stroke(4.0, StrokePosition::Inside)]] { + let mut l = Layer::raster("extra", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(14, 100, 34, 130), &photocraft_raster::from_rgba(&d.pixel_format(), [0.3, 0.6, 0.9, 1.0])); + l.effects.items = fx; + d.layers.push(l); + } + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&d, &patterns); + let mut todo = Vec::new(); + effect_layers(&d.layers, d.bounds(), 0, &mut todo); + assert!(todo.len() > 12); + for l in &todo { + let built = effect_maps(l, d.bounds(), &cx); + let region = effect_region(l, d.bounds(), &cx).0; + let counted = effects::map_count(l) * region.width() as usize * region.height() as usize * 4; + assert_eq!(counted, built.bytes(), "layer {}", l.name); + } + // 9000 x 6000 documents, every layer full size. + let big = |fx: Vec, n: usize| { + let mut big = Document::with_background("big", Size::new(9000, 6000), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut l = Layer::raster("fill", big.pixel_format()); + l.surface_mut().unwrap().fill_rect(big.bounds(), &photocraft_raster::from_rgba(&big.pixel_format(), [0.8, 0.5, 0.3, 1.0])); + l.effects.items = fx; + big.layers.extend(std::iter::repeat_n(l, n)); + fx_region_bytes(&big.layers, big.bounds()) + }; + let budget = DEFAULT_FX_CACHE_BUDGET as u64; + // #8330's case: three drop shadows (about 1.3 GB) are over the default 768 MB; one fits. + let shadow = Effect::DropShadow(Shadow { size: 40.0, ..shadow(20.0, 120.0) }); + assert!(big(vec![shadow.clone()], 1) < budget); + assert!(big(vec![shadow], 3) > budget); + // A narrow and a wide stroke: the shape, the inside distance and both outside fields. + assert!(big(vec![stroke(2.0, StrokePosition::Outside), stroke(60.0, StrokePosition::Outside)], 1) > budget); + // Colour overlays need the shape only: three fit. + let overlay = Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }; + assert!(big(vec![overlay], 3) < budget); +} + // ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ---------- #[test] @@ -1538,6 +2152,7 @@ fn emboss_shades_the_composited_layer() { altitude: 30.0, use_global_light: false, gloss_contour: photocraft_doc::Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), highlight_color: Color::WHITE, shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0), @@ -1587,6 +2202,7 @@ fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + overprint: false, }); let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]); assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20)); @@ -2517,6 +3133,7 @@ fn nested_parallel_fx_doc() -> Document { size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, })]; let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0))); glow.opacity = 0.5; @@ -2547,6 +3164,7 @@ fn nested_parallel_fx_doc() -> Document { altitude: 32.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), @@ -2884,6 +3502,55 @@ fn lab_adjustments_work_on_the_stored_values() { near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); } +// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves +// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact +// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits +// round to 1/32768. +#[test] +fn lab_adjustment_results_are_whole_samples() { + let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::>(); + let curves = |l: &[(f32, f32)]| Adjustment::Curves { + master: Vec::new(), + per_channel: [pts(l), Vec::new(), Vec::new()], + space: ToneSpace::Lab, + black: Vec::new(), + pencil: false, + }; + // A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes. + let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]); + for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] { + let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]); + let p = px(&doc, 1, 1); + let stack = native_lab::LabStack::new(&doc); + assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path"); + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0; + assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q); + } +} + +// a and b round too, after the opacity mix: Curves taking a 140 to 151.4 at 50% opacity gives +// 145.7, stored as 146 (rounding before the mix would leave 145.5, truncating 145). 8 bits only: read back through +// display RGB, a is not exact enough to tell 1/32768 steps apart. +#[test] +fn lab_adjustment_results_round_a_and_b_after_the_opacity_mix() { + let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::>(); + let adj = Adjustment::Curves { + master: Vec::new(), + per_channel: [Vec::new(), pts(&[(0.0, 0.0), (140.0 / 255.0, 151.4 / 255.0), (1.0, 1.0)]), Vec::new()], + space: ToneSpace::Lab, + black: Vec::new(), + pencil: false, + }; + let mut doc = lab_stack([0.5, 140.0 / 255.0, 0.5], SampleType::U8, vec![adj]); + if let Some(l) = doc.layers.last_mut() { + l.opacity = 0.5; + } + assert!(native_lab::LabStack::new(&doc).is_some_and(|s| s.adjusts()), "the native path"); + let p = px(&doc, 1, 1); + let a = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[1] + 128.0; + assert!((a - 146.0).abs() < 0.02, "a {a} is not 146"); +} + // Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB // compositor clipped them on the way through display RGB). #[test] @@ -3088,6 +3755,108 @@ fn effect_cache_follows_a_group_childs_blend_if() { assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); } +/// A document whose one-colour areas are found: shared background tiles, a painted patch over +/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one. +fn sparse_doc(w: u32, h: u32) -> Document { + let mut d = doc_white(w, h); + let mut paint = Layer::raster("paint", PixelFormat::RGBA8); + let s = paint.surface_mut().unwrap(); + for y in 200..330 { + s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]); + } + s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]); + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]); + m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]); + paint.mask = Some(m); + paint.blend = BlendMode::Multiply; + let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]); + deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true))); + let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]); + hidden.visible = false; + d.layers.push(Layer::group("g", vec![paint, deep, hidden])); + d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 }))); + d +} + +#[test] +fn one_colour_areas_reduce_like_composited_ones() { + // #4017: one-colour tiles are composited once and summed per run of columns: the same + // averages as compositing every pixel (to float rounding), and a damage render gives exactly + // the whole reduction's pixels, so a reduced canvas updated in parts shows no seams. + let d = sparse_doc(2000, 2100); + for (b, w, h, band) in [ + (d.bounds(), 37, 41, 0), + (d.bounds(), 250, 2100, 256), + (d.bounds(), 1999, 3, 512), + (Rect::new(-300, -77, 2200, 2300), 61, 29, 0), + (Rect::new(13, 5, 2000, 2100), 1, 1, 256), + ] { + let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default()); + let mut stats = ReducedStats::default(); + let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats); + assert!(stats.uniform); + assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}"); + assert_eq!(all.rect, reference.rect); + for (p, q) in all.px.iter().zip(&reference.px) { + assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}"); + } + for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] { + let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default()); + for y in part.rect.y0..part.rect.y1 { + for x in part.rect.x0..part.rect.x1 { + assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}"); + } + } + } + } +} + +#[test] +fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() { + let base = sparse_doc(300, 300); + let edits: [fn(&mut Layer); 4] = [ + |l| l.blend = BlendMode::Dissolve, + |l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0, + |l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)), + |l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)), + ]; + for edit in edits { + let mut d = base.clone(); + edit(&mut d.layers[0]); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); + assert_eq!(stats.composited, 300 * 300); + } + let mut d = base.clone(); + d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true }))); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); +} + +#[test] +fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() { + // #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel + // (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges + // hold two values; the rest is one shared white tile. + let (w, h) = (78_740u32, 39_370u32); + for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] { + let mut d = match fill { + Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c), + None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8), + }; + d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8)); + let mut stats = ReducedStats::default(); + let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats); + let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1); + assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}"); + let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]); + assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}"); + } +} + #[test] fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_effects() { let canvas = Rect::new(0, 0, 100, 100); @@ -3214,3 +3983,217 @@ fn concurrent_effect_identity_misses_compute_once() { }); assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); } + +#[test] +fn reused_render_matches_previous_allocating_stack() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("equivalence", Size::new(513, 7), ColorMode::Rgb, depth); + let rect = doc.bounds(); + for (name, color) in [("bottom", [0.2, 0.4, 0.8, 1.0]), ("top", [1.5, -0.25, 0.6, 0.5])] { + let mut layer = Layer::raster(name, doc.pixel_format()); + layer.surface_mut().unwrap().fill_rect(rect, &color); + doc.layers.push(layer); + } + let mut levels = Adjustment::identity_levels(); + if let Adjustment::Levels { master, .. } = &mut levels { + master.gamma = 1.4; + } + for adj in [levels, Adjustment::identity_curves()] { + let mut layer = Layer::new("tone", LayerContent::Adjustment(adj)); + layer.opacity = 0.6; + doc.layers.push(layer); + } + let mut previous = Buffer::transparent(rect); + in_blend_space(false, depth == SampleType::F32, || { + for layer in &doc.layers { + if let LayerContent::Adjustment(adj) = &layer.content { + let mut adjusted = previous.clone(); + adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode); + for (b, a) in previous.px.iter_mut().zip(&adjusted.px) { + let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); + *b = [ + b[0] + (blended[0] - b[0]) * layer.opacity, + b[1] + (blended[1] - b[1]) * layer.opacity, + b[2] + (blended[2] - b[2]) * layer.opacity, + b[3], + ]; + } + if let Some(q) = adjustment_quantum(depth) { + quantize(&mut previous, q); + } + } else { + let content = surface_to_buffer(layer.surface().unwrap(), rect); + blend_into_fill(&mut previous, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); + } + } + }); + for tile in [1, 127, 256, 1024] { + assert_buffer_bits(&render_tiled(&doc, rect, tile), &previous); + } + } +} + +#[test] +fn fully_masked_adjustment_tiles_preserve_output_and_skip_tone_work() { + let rect = Rect::new(-1, -1, 2, 1); + for depth in SampleType::ALL { + for partial in [false, true] { + let mut doc = Document::new("masked", Size::new(3, 2), ColorMode::Rgb, depth); + let mut adj = Adjustment::identity_curves(); + if let Adjustment::Curves { master, .. } = &mut adj { + master[1].output = 0.8; + } + let mut layer = Layer::new("curves", LayerContent::Adjustment(adj.clone())); + layer.opacity = 0.6; + let mut mask = LayerMask::hide_all(); + if partial { + mask.surface.write_pixel(0, 0, &[0.75]); + } + layer.mask = Some(mask); + doc.layers.push(layer); + let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&doc, &patterns); + let before = Buffer::filled(rect, [0.2, -0.0, 0.7, 0.6]); + let mut adjusted = before.clone(); + adjust::apply_in(&adj, &mut adjusted, cx.transfer, Some(depth), doc.mode); + let mv = mask_vals(&doc.layers[0], rect, &cx); + let mut expected = before.clone(); + for (i, (b, a)) in expected.px.iter_mut().zip(&adjusted.px).enumerate() { + let k = 0.6 * mask_k(&mv, i); + if k > 0.0 { + for c in 0..3 { + b[c] += (a[c] - b[c]) * k; + } + } + } + let clipped = if partial { vec![] } else { vec![solid_layer("clipped", rect, [0.9, 0.1, 0.2, 1.0])] }; + let mut actual = before; + composite_layer_plain(&doc.layers[0], &clipped, &mut actual, &cx, advanced::Scope::ROOT); + assert_buffer_bits(&actual, &expected); + assert_eq!(cx.tone_luts[0].1.get().is_some(), partial); + } + } +} + +#[test] +fn dissolve_adjustment_resolves_opacity_to_binary_coverage() { + let mut d = doc_white(16, 16); + let mut layer = Layer::new("Invert", LayerContent::Adjustment(Adjustment::Invert)); + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.5; + d.layers.push(layer); + let pixels = render(&d, d.bounds()).px; + let black = pixels.iter().filter(|p| p[0] < 0.01).count(); + let white = pixels.iter().filter(|p| p[0] > 0.99).count(); + assert!(black > 0 && white > 0, "Dissolve must select both original and adjusted pixels"); + assert_eq!(black + white, pixels.len(), "no intermediate gray pixels"); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated"); +} + +#[test] +fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() { + let mut d = doc_white(16, 16); + let mut fill = Layer::new("Black", LayerContent::Fill(Fill::Solid(Color::BLACK))); + fill.blend = BlendMode::Dissolve; + fill.opacity = 0.5; + d.layers.push(fill); + let control = render(&d, d.bounds()).px; + d.layers.last_mut().unwrap().clipped = true; + let clipped = render(&d, d.bounds()).px; + assert_eq!(clipped, control, "clipping over a fully opaque base must preserve dissolve pixels"); + let black = clipped.iter().filter(|p| p[0] < 0.01).count(); + assert!(black > 0 && black < clipped.len()); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); +} + +#[test] +fn dissolve_color_overlay_preserves_binary_layer_coverage() { + let mut d = doc_white(16, 16); + let mut layer = Layer::new("Black", LayerContent::Fill(Fill::Solid(Color::BLACK))); + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.5; + d.layers.push(layer); + let control = render(&d, d.bounds()).px; + d.layers + .last_mut() + .unwrap() + .effects + .items + .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); + let styled = render(&d, d.bounds()).px; + assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray"); + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let continuous = render(&d, d.bounds()).px; + assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); +} + +#[test] +fn clipped_dissolve_adjustment_preserves_alpha_and_document_coverage() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("clipped adjustment", Size::new(272, 12), ColorMode::Rgb, fmt.sample); + let bounds = Rect::new(-3, -2, 272, 12); + let mut base = Layer::raster("base", fmt); + base.surface_mut().unwrap().fill_rect(bounds, &[1.0, 1.0, 1.0, 0.5]); + d.layers.push(base); + let original = render(&d, bounds); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.clipped = true; + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.6; + adjustment.fill_opacity = 0.5; + let mut mask = photocraft_raster::Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-3, -2, 260, 12), &[1.0]); + adjustment.mask = Some(LayerMask { surface: mask, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(adjustment); + let whole = render(&d, bounds); + let mut changed = 0; + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + let p = whole.get(x, y); + let value = if x < 260 && dissolve_noise(x, y) < 0.3 { + changed += 1; + 0.0 + } else { + 1.0 + }; + assert!(close4(p, [value, value, value, original.get(x, y)[3]]), "{fmt:?} at ({x}, {y}): {p:?}"); + } + } + assert!(changed > 0 && changed < whole.px.len()); + let region = Rect::new(250, 1, 270, 10); + let part = render(&d, region); + for y in region.y0..region.y1 { + for x in region.x0..region.x1 { + assert!(close4(part.get(x, y), whole.get(x, y)), "{fmt:?} region at ({x}, {y})"); + } + } + d.layers.last_mut().unwrap().blend = BlendMode::Normal; + let normal = render(&d, bounds); + assert!(normal.px.iter().any(|p| p[0] > 0.01 && p[0] < 0.99), "Normal keeps continuous mixing"); + } +} + +#[test] +fn branchless_floor_matches_floor_bitwise() { + // Every 4099th bit pattern (all exponents, both signs, NaNs and infinities among them), plus + // the values either side of the whole numbers and of 2^23 where the rounding steps. + let mut xs: Vec = (0..=u32::MAX).step_by(4099).map(f32::from_bits).collect(); + for w in [0.0f32, 1.0, 2.0, 254.0, 255.0, 32768.0, 4_194_303.0, 4_194_304.0, 8_388_607.0, 8_388_608.0, 16_777_216.0] { + for v in [w, -w] { + xs.extend([v, v.next_up(), v.next_down(), v + 0.5, v - 0.5]); + } + } + xs.extend([f32::INFINITY, f32::NEG_INFINITY, f32::NAN, -f32::NAN, f32::MIN_POSITIVE, -f32::MIN_POSITIVE, f32::MAX, f32::MIN]); + for x in xs { + // NaN payloads aren't portable: MSVC's floor quiets a signalling NaN, so NaN only has to stay NaN. + if x.is_nan() { + assert!(branchless_floor(x).is_nan(), "{:#010x}", x.to_bits()); + continue; + } + assert_eq!(branchless_floor(x).to_bits(), x.floor().to_bits(), "{x:e} ({:#010x})", x.to_bits()); + } +} diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs new file mode 100644 index 0000000000..e9423d0e69 --- /dev/null +++ b/crates/compose/src/uniform.rs @@ -0,0 +1,107 @@ +//! One-colour areas of a composite, known without compositing them pixel by pixel. +//! +//! A new document is a canvas of one shared white tile (or no tiles at all), and a sparse one is +//! mostly that. When every layer composites pixel by pixel from its own pixels (no effects, +//! feathered masks, dissolve, dithering or layer kinds laid out on the canvas), an area over which +//! every surface holds one value composites to one colour: one pixel of it is composited and +//! stands for the rest. The reduced canvas of a gigapixel document (#4017) then costs what its +//! painted tiles cost, not a composite of every document pixel. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use photocraft_color::blend::BlendMode; +use photocraft_doc::{Adjustment, Document, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// The surfaces a document composites from, when its composite at each pixel depends only on +/// their values at that pixel. +pub(crate) struct UniformAreas<'a> { + surfaces: Vec<&'a Surface>, + /// One value per tile (by address), or `None` when its pixels differ: each tile is scanned once. + tile_values: Mutex>>>, + /// The composite colour of each combination of surface values met so far. + colors: Mutex, [f32; 4]>>, +} + +impl<'a> UniformAreas<'a> { + /// `None` when some layer's composite depends on more than its pixels at the same place. + pub(crate) fn new(doc: &'a Document) -> Option { + if doc.mode == photocraft_color::ColorMode::Multichannel { + return None; + } + let mut surfaces = Vec::new(); + for (_, _, l) in doc.walk() { + let effects = l.effects.enabled && !l.effects.items.is_empty(); + if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve { + return None; + } + if let Some(m) = &l.mask { + if m.feather != 0.0 { + return None; + } + surfaces.push(&m.surface); + } + match &l.content { + LayerContent::Raster(s) => surfaces.push(s), + LayerContent::Group(g) if g.artboard.is_none() => {} + LayerContent::Adjustment(a) if per_pixel(a) => {} + _ => return None, + } + } + Some(Self { surfaces, tile_values: Mutex::default(), colors: Mutex::default() }) + } + + /// The colour every pixel of `r` composites to, when every surface holds one value over it; + /// `composite` renders one pixel the first time a combination of values is met. + pub(crate) fn color(&self, r: Rect, composite: impl FnOnce(Rect) -> Option<[f32; 4]>) -> Option<[f32; 4]> { + if r.is_empty() { + return None; + } + let mut key = Vec::new(); + for s in &self.surfaces { + key.extend_from_slice(&self.value_over(s, r)?); + } + if let Some(c) = self.colors.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + return Some(*c); + } + let c = composite(Rect::from_xywh(r.x0, r.y0, 1, 1))?; + self.colors.lock().unwrap_or_else(PoisonError::into_inner).insert(key, c); + Some(c) + } + + /// The one encoded value `s` holds over `r`, if it holds one. + fn value_over(&self, s: &Surface, r: Rect) -> Option> { + let mut found: Option> = None; + for c in r.tiles() { + let v = match s.tile(c) { + None => Some(Box::from(s.default_bytes())), + Some(t) => { + let id = std::sync::Arc::as_ptr(t) as usize; + let cached = self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).get(&id).cloned(); + match cached { + Some(v) => v, + None => { + let bpp = s.default_bytes().len().max(1); + let mut px = t.bytes().chunks_exact(bpp); + let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); + self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); + v + } + } + } + }?; + if found.as_ref().is_some_and(|f| *f != v) { + return None; + } + found = Some(v); + } + found + } +} + +/// Whether adjustment `a` maps each pixel on its own (dithered ones vary with the position). +fn per_pixel(a: &Adjustment) -> bool { + !matches!(a, Adjustment::GradientMap { dither: true, .. } | Adjustment::ColorLookup { dither: true, .. }) +} diff --git a/crates/doc/src/analysis.rs b/crates/doc/src/analysis.rs index 840bf54990..9b1d8efdb0 100644 --- a/crates/doc/src/analysis.rs +++ b/crates/doc/src/analysis.rs @@ -30,14 +30,12 @@ impl MeasurementScale { } /// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`. pub fn describe(&self) -> String { - format!("{} pixels = {:.4} {}", trim(self.pixel_length), self.logical_length, self.units) + // f64 Display already drops a whole number's `.0` (`5`, `0.5`) and, unlike an i64 cast, + // prints lengths past i64::MAX in full. + format!("{} pixels = {:.4} {}", self.pixel_length, self.logical_length, self.units) } } -fn trim(v: f64) -> String { - if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") } -} - /// One Count tool group: its numbered markers (document pixel coordinates, in click order). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -133,6 +131,17 @@ mod tests { assert_eq!(MeasurementScale { pixel_length: 0.0, ..Default::default() }.factor(), 1.0); } + #[test] + fn scale_text_prints_large_and_fractional_pixel_lengths() { + let big = MeasurementScale { pixel_length: 1e20, logical_length: 1.0, units: "mm".into() }; + assert_eq!(big.describe(), "100000000000000000000 pixels = 1.0000 mm"); + assert_eq!(MeasurementScale::default().describe(), "1 pixels = 1.0000 pixels"); + let s = |p: f64| MeasurementScale { pixel_length: p, ..Default::default() }.describe(); + assert_eq!(s(5.0), "5 pixels = 1.0000 pixels"); + assert_eq!(s(0.5), "0.5 pixels = 1.0000 pixels"); + assert_eq!(s(12.25), "12.25 pixels = 1.0000 pixels"); + } + #[test] fn serde_defaults_fill_missing_fields() { let m: Measurement = serde_json::from_str("{}").unwrap(); diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 6ae790e26c..7f0e34e9b0 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -166,6 +166,9 @@ pub struct StrokeFx { pub size: f32, pub position: StrokePosition, pub paint: FxPaint, + /// Overprint (PSD `overprint`). + #[serde(default)] + pub overprint: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -213,6 +216,9 @@ pub struct Bevel { pub altitude: f32, pub use_global_light: bool, pub gloss_contour: Contour, + /// Gloss Contour › Anti-aliased (PSD `antialiasGloss`). + #[serde(default)] + pub gloss_anti_alias: bool, pub highlight: FxCommon, pub highlight_color: Color, pub shadow: FxCommon, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a1214e7938..9e79d10da2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -659,6 +659,10 @@ pub struct Layer { pub advanced: AdvancedBlending, /// Layer › Video Layers frame stack (None for a normal layer). pub video: Option, + /// The layer's own XMP packet (Photoshop's `layer.xmpMetadata.rawData`), where scripts and + /// embedding apps keep data about the layer under their own namespaces. Copied by + /// [`Layer::duplicate`], as in Photoshop. PSD `shmd` › `cust` › `layerXMP`. + pub xmp: Option>, } impl Layer { @@ -692,6 +696,7 @@ impl Layer { blend_if: BlendIf::default(), advanced: AdvancedBlending::default(), video: None, + xmp: None, } } pub fn raster(name: impl Into, format: PixelFormat) -> Self { @@ -912,8 +917,8 @@ pub struct Document { pub variables: Variables, /// Window › Timeline (None until a video timeline is created). pub timeline: Option, - /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so - /// a save writes it back the same way rather than at fixed defaults. + /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless, + /// PNG stereo pairing), so a save writes it back the same way rather than at fixed defaults. pub file_encoding: FileEncoding, } @@ -932,6 +937,11 @@ pub struct FileEncoding { /// Lossy WebP quality. #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, + /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` + /// cross-fuse (right eye first), `Some(1)` diverging-fuse (left eye first). Both are + /// side by side; saving a stereo PNG keeps this eye-order mark (#6063). + #[serde(skip_serializing_if = "Option::is_none")] + pub png_stereo: Option, } impl FileEncoding { diff --git a/crates/doc/src/mode.rs b/crates/doc/src/mode.rs index 96e129cbee..9722ce5b15 100644 --- a/crates/doc/src/mode.rs +++ b/crates/doc/src/mode.rs @@ -74,7 +74,7 @@ impl Duotone { let density = 1.0 - gray.clamp(0.0, 1.0); let mut out = [1.0f32; 3]; for ink in &self.inks { - let d = curve_eval(&ink.curve, density); + let d = ink.density(density); for (o, c) in out.iter_mut().zip(ink.color) { // Each ink filters light: full density transmits the ink colour. *o *= 1.0 - d * (1.0 - c); @@ -209,6 +209,22 @@ mod tests { assert!(c[0] > c[2], "{c:?}"); } + #[test] + fn duotone_render_clamps_ink_density_like_multichannel() { + // Curves above 100 % (accepted by `image.mode.duotone`) must not push the print out of + // gamut: red + blue at full density is black, not green. + let over = |name: &str, color| { + let mut ink = DuotoneInk::new(name, color); + ink.curve = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 2.0 }]; + ink + }; + let d = Duotone { inks: vec![over("Red", [1.0, 0.0, 0.0]), over("Blue", [0.0, 0.0, 1.0])], psd_raw: None }; + assert_eq!(d.render(0.0), [0.0, 0.0, 0.0]); + for g in [0.0, 0.25, 0.5, 1.0] { + assert!(d.render(g).iter().all(|c| (0.0..=1.0).contains(c)), "{g}: {:?}", d.render(g)); + } + } + #[test] fn stack_mode_ids_roundtrip() { for m in StackMode::ALL { diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 2705bdf5f0..7fb6ba3ad5 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -168,10 +168,17 @@ pub struct ResolvedSlice { } /// The visible part of every stored slice (later slices cover earlier ones, as in Photoshop), -/// clipped to the canvas. -fn clipped(doc: &Document) -> Vec<(u32, Rect)> { +/// clipped to the canvas: a covered slice is cut into the rectangles that stay visible, each +/// paired with the slice's index in the list. +fn visible(doc: &Document) -> Vec<(usize, Rect)> { let canvas = doc.bounds(); - doc.slices.list.iter().map(|s| (s.id, s.rect.intersect(&canvas))).filter(|(_, r)| !r.is_empty()).collect() + let rects: Vec = doc.slices.list.iter().map(|s| s.rect.intersect(&canvas)).collect(); + let mut out = Vec::new(); + for (i, r) in rects.iter().enumerate() { + let above = rects.get(i + 1..).unwrap_or_default(); + out.extend(auto_slices(*r, above).into_iter().map(|p| (i, p))); + } + out } /// Rectangles that tile `canvas` minus `taken`. The canvas is cut into a grid along every slice @@ -195,24 +202,42 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec { xs.dedup(); ys.sort_unstable(); ys.dedup(); - let covered = |x0: i32, y0: i32| taken.iter().any(|r| x0 >= r.x0 && x0 < r.x1 && y0 >= r.y0 && y0 < r.y1); - // Horizontal runs per row band. + // Clip the taken rectangles to the canvas once, so band coverage only considers rects that + // can actually cover a cell. + let clipped: Vec = taken.iter().map(|r| r.intersect(&canvas)).filter(|r| !r.is_empty()).collect(); + // Map an x edge to its cell index in `xs` (xs is sorted/deduped). + let x_cell = |x: i32| xs.partition_point(|&e| e <= x).saturating_sub(1); + // Horizontal runs per row band. For each band, mark covered x-cells in a single pass over + // the rects intersecting that band, instead of scanning every rect for every cell (which + // was cubic in the slice count and let a crafted document stall canvas drawing). + let ncells = xs.len().saturating_sub(1); let mut runs: Vec = Vec::new(); for yw in ys.windows(2) { + let (band_y0, band_y1) = (yw[0], yw[1]); + let mut covered_cell = vec![false; ncells]; + for r in &clipped { + if r.y0 <= band_y0 && band_y1 <= r.y1 { + // Rect spans this whole band vertically; mark the x-cells it covers. + let (c0, c1) = (x_cell(r.x0), x_cell(r.x1)); + for c in covered_cell.iter_mut().take(c1).skip(c0) { + *c = true; + } + } + } let mut start: Option = None; - for xw in xs.windows(2) { - let free = !covered(xw[0], yw[0]); + for (ci, xw) in xs.windows(2).enumerate() { + let free = !covered_cell.get(ci).copied().unwrap_or(false); match (free, start) { (true, None) => start = Some(xw[0]), (false, Some(s)) => { - runs.push(Rect::new(s, yw[0], xw[0], yw[1])); + runs.push(Rect::new(s, band_y0, xw[0], band_y1)); start = None; } _ => {} } } if let Some(s) = start { - runs.push(Rect::new(s, yw[0], *xs.last().unwrap_or(&canvas.x1), yw[1])); + runs.push(Rect::new(s, band_y0, *xs.last().unwrap_or(&canvas.x1), band_y1)); } } // Merge vertically adjacent runs with the same columns. @@ -229,26 +254,31 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec { /// Every slice of `doc` in display order (see [`ResolvedSlice`]). Without user or layer slices /// the whole canvas is one auto slice named `` alone (as Photoshop's Save for Web names -/// it); with several, generated names are numbered `_NN`. +/// it); with several, generated names are numbered `_NN`. A slice covered by later ones +/// appears once per visible piece: the first piece keeps its name, the others are `-NN`. pub fn resolve(doc: &Document) -> Vec { let base = base_name(doc); - let stored = clipped(doc); + let stored = visible(doc); let rects: Vec = stored.iter().map(|(_, r)| *r).collect(); - let mut all: Vec<(Option, Rect)> = stored.iter().map(|(id, r)| (Some(*id), *r)).collect(); + let mut all: Vec<(Option, Rect)> = stored.iter().map(|(i, r)| (Some(*i), *r)).collect(); all.extend(auto_slices(doc.bounds(), &rects).into_iter().map(|r| (None, r))); all.sort_by_key(|(_, r)| (r.y0, r.x0)); let single = all.len() == 1; + let mut named = vec![false; doc.slices.list.len()]; all.into_iter() .enumerate() - .map(|(i, (id, rect))| { - let s = id.and_then(|id| doc.slices.get(id)); - let number = i + 1; + .map(|(n, (i, rect))| { + let s = i.and_then(|i| doc.slices.list.get(i)); + let number = n + 1; + let first = i.and_then(|i| named.get_mut(i)).is_none_or(|seen| !std::mem::replace(seen, true)); let name = match s.map(|s| s.name.as_str()).filter(|n| !n.is_empty()) { - Some(n) => n.to_string(), + Some(n) if first => n.to_string(), + Some(n) => format!("{n}-{number:02}"), None if single => base.clone(), None => format!("{base}_{number:02}"), }; - ResolvedSlice { number, id, origin: s.map_or(SliceOrigin::Auto, |s| s.origin), rect, name, kind: s.map_or(SliceKind::Image, |s| s.kind) } + let (id, origin, kind) = s.map_or((None, SliceOrigin::Auto, SliceKind::Image), |s| (Some(s.id), s.origin, s.kind)); + ResolvedSlice { number, id, origin, rect, name, kind } }) .collect() } @@ -287,6 +317,53 @@ mod tests { assert_eq!(auto_slices(canvas, &[]), vec![canvas]); } + #[test] + fn auto_slices_cover_exactly_with_many_overlapping_slices() { + // Several overlapping slices form a non-trivial grid. The per-band coverage pass must + // still tile the complement exactly: auto area + taken-union area == canvas, with no + // auto slice overlapping a taken rect or another auto slice. + let canvas = Rect::new(0, 0, 200, 150); + let taken = [Rect::new(10, 10, 90, 70), Rect::new(50, 40, 160, 120), Rect::new(120, 0, 200, 50), Rect::new(0, 100, 60, 150)]; + let auto = auto_slices(canvas, &taken); + // No auto slice intersects any taken rect. + for a in &auto { + for t in &taken { + assert!(a.intersect(t).is_empty(), "{a:?} overlaps taken {t:?}"); + } + } + // Auto slices do not overlap each other. + for (i, a) in auto.iter().enumerate() { + for b in &auto[i + 1..] { + assert!(a.intersect(b).is_empty(), "{a:?} overlaps {b:?}"); + } + } + // Auto area equals the canvas minus the union of taken rects (computed on the same grid). + let union_cells: i64 = { + // Sum cell areas covered by at least one taken rect, over the fine grid. + let mut xs = vec![canvas.x0, canvas.x1]; + let mut ys = vec![canvas.y0, canvas.y1]; + for t in &taken { + xs.extend([t.x0, t.x1]); + ys.extend([t.y0, t.y1]); + } + xs.sort_unstable(); + xs.dedup(); + ys.sort_unstable(); + ys.dedup(); + let mut sum = 0i64; + for yw in ys.windows(2) { + for xw in xs.windows(2) { + let covered = taken.iter().any(|r| xw[0] >= r.x0 && xw[0] < r.x1 && yw[0] >= r.y0 && yw[0] < r.y1); + if covered { + sum += i64::from(xw[1] - xw[0]) * i64::from(yw[1] - yw[0]); + } + } + } + sum + }; + assert_eq!(area(&auto) + union_cells, i64::from(canvas.width()) * i64::from(canvas.height())); + } + #[test] fn resolve_numbers_top_to_bottom() { let mut d = Document::new("site.psd", Size::new(100, 80), ColorMode::Rgb, SampleType::U8); @@ -303,6 +380,30 @@ mod tests { assert!(resolve(&d).iter().any(|s| s.name == "logo")); } + #[test] + fn later_slices_cover_earlier_ones() { + // Photoshop's Save for Web on this document writes sliceA [0,0,60,40], o82_02, sliceA-03 + // [0,40,40,60], sliceB and o82_05: the covered slice is cut into its visible pieces. + let mut d = Document::new("o82.psd", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + d.slices.list.push(Slice { id: 1, name: "sliceA".into(), rect: Rect::new(0, 0, 60, 60), ..Default::default() }); + d.slices.list.push(Slice { id: 2, name: "sliceB".into(), rect: Rect::new(40, 40, 100, 100), ..Default::default() }); + let r = resolve(&d); + let got: Vec<(&str, Option, Rect)> = r.iter().map(|s| (s.name.as_str(), s.id, s.rect)).collect(); + assert_eq!( + got, + [ + ("sliceA", Some(1), Rect::new(0, 0, 60, 40)), + ("o82_02", None, Rect::new(60, 0, 100, 40)), + ("sliceA-03", Some(1), Rect::new(0, 40, 40, 60)), + ("sliceB", Some(2), Rect::new(40, 40, 100, 100)), + ("o82_05", None, Rect::new(0, 60, 40, 100)), + ] + ); + d.slices.list[0].name.clear(); + let names: Vec = resolve(&d).into_iter().filter(|s| s.id == Some(1)).map(|s| s.name).collect(); + assert_eq!(names, ["o82_01", "o82_03"]); + } + #[test] fn exhausted_slice_id_survives_serialization() { let slices = Slices { list: vec![Slice { id: u32::MAX, ..Default::default() }], ..Default::default() }; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 6f7836fa46..900e487f07 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -32,6 +32,16 @@ pub enum Caps { AllCaps, } +/// Superscript / subscript (Photoshop's `FontBaseline`). The run is drawn smaller and raised or +/// lowered; `size_pt` and `baseline_shift_pt` keep their own values. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FontBaseline { + #[default] + Normal, + Superscript, + Subscript, +} + /// An OpenType feature setting, e.g. `{ tag: "ss01", value: 1 }`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FontFeature { @@ -79,6 +89,8 @@ pub struct CharStyle { /// per character run as `Kerning`). Added to the automatic kerning of the pair. pub kern: f32, pub caps: Caps, + /// Superscript or subscript. + pub baseline: FontBaseline, /// Standard ligatures (`liga`/`clig`). pub ligatures: bool, /// Discretionary ligatures (`dlig`). @@ -117,6 +129,7 @@ impl Default for CharStyle { kerning: Kerning::Metrics, kern: 0.0, caps: Caps::Normal, + baseline: FontBaseline::Normal, ligatures: true, discretionary_ligatures: false, features: Vec::new(), diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..a66aed8587 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -192,7 +192,8 @@ pub struct VectorMask { pub linked: bool, /// Density `0..=1` (Properties panel), like [`crate::LayerMask::density`]. pub density: f32, - /// Feather radius in px (stored; rendered unfeathered for now). + /// Feather radius in px (Properties panel); the compositor blurs the rasterised path by it + /// (`photocraft_compose::masks::feather_sigma`), as it does a pixel mask's feather. pub feather: f32, } @@ -302,10 +303,14 @@ pub enum LiveShape { rect: [f64; 4], }, /// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon). + /// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the + /// Triangle tool draws a 3-sided one. Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, + #[serde(default)] + corner_radius: f64, }, /// Line from `from` to `to` drawn as a filled bar `weight` px thick. Line { @@ -316,7 +321,7 @@ pub enum LiveShape { } /// A shape layer: a filled and/or stroked path. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct ShapeLayer { pub path: Path, /// `None` = no fill (Photoshop's fill "none"). @@ -328,6 +333,25 @@ pub struct ShapeLayer { /// Data of the PSD vector mask block (`vsms`, else `vmsk`). On export it replaces that entry /// in [`crate::Layer::psd_blocks`] while it still decodes to `path`. pub psd_raw: Option>>, + /// Density `0..=1` of the path as the fill's mask (Properties › Masks, like + /// [`VectorMask::density`]): below 1 the fill shows beyond the path at `1 - density`. + pub density: f32, + /// Feather radius in px of the path as the fill's mask (like [`VectorMask::feather`]). + pub feather: f32, +} + +impl Default for ShapeLayer { + fn default() -> Self { + ShapeLayer { path: Path::default(), fill: None, stroke: None, live: None, cache: None, psd_raw: None, density: 1.0, feather: 0.0 } + } +} + +impl ShapeLayer { + /// Whether the fill's edge is softened by a density below 100 % or a feather (non-finite + /// values count as the defaults). + pub fn has_soft_mask(&self) -> bool { + (self.density.is_finite() && self.density < 1.0) || (self.feather.is_finite() && self.feather > 0.0) + } } #[cfg(test)] @@ -352,6 +376,15 @@ mod tests { assert_eq!(t.control_bounds(), Some((2.0, 3.0, 6.0, 10.0))); } + #[test] + fn a_polygon_saved_before_corner_radii_loads_sharp() { + let old = r#"{"kind":"polygon","rect":[0.0,0.0,10.0,10.0],"sides":3,"star_ratio":1.0}"#; + let back: LiveShape = serde_json::from_str(old).unwrap(); + assert_eq!(back, LiveShape::Polygon { rect: [0.0, 0.0, 10.0, 10.0], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); + let round = LiveShape::Polygon { rect: [0.0; 4], sides: 6, star_ratio: 1.0, corner_radius: 4.5 }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&round).unwrap()).unwrap(), round); + } + #[test] fn serde_roundtrip() { let p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (3.0, 4.0)]).with_op(PathOp::Exclude)]); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..ec5b4ac075 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,19 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +chrono = { workspace = true } + +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" +# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding +# ui-egui and craft-print already use, pinned to the same version. +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } + +# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. +[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] +photocraft-ipp = { workspace = true } [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } diff --git a/crates/engine/examples/bench_crop.rs b/crates/engine/examples/bench_crop.rs new file mode 100644 index 0000000000..1d301ff19c --- /dev/null +++ b/crates/engine/examples/bench_crop.rs @@ -0,0 +1,40 @@ +//! End-to-end timing of `image.crop`, upright and turned, on a two-layer document. +//! `cargo run --release -p photocraft-engine --example bench_crop -- [image] [reps]` +//! Without an image a 3859×2594 noise photo stands in (the size of the Great Wave scan). +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn main() { + let args: Vec = std::env::args().collect(); + let reps: usize = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut s = Session::new(); + match args.get(1) { + Some(path) => { + let bytes = std::fs::read(path).expect("read the image"); + let doc = photocraft_io::import(path, &bytes).expect("decode the image").document; + s.add_document(doc, None); + } + None => { + s.execute("file.new", json!({"width": 3859, "height": 2594, "background": "white"})).expect("new"); + s.execute("filter.noise.addNoise", json!({"amount": 50})).expect("noise"); + } + } + s.execute("layer.duplicate", json!({})).expect("a second layer"); + let cases: [(&str, Value); 3] = [ + ("upright 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442})), + ("turned 3° 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442, "angle": 3})), + ("turned 3° 3000x2000", json!({"x": 400, "y": 300, "width": 3000, "height": 2000, "angle": 3})), + ]; + for (label, p) in cases { + let mut best = Duration::MAX; + for _ in 0..reps { + let t = Instant::now(); + s.execute("image.crop", p.clone()).expect("crop"); + best = best.min(t.elapsed()); + s.execute("edit.undo", json!({})).expect("undo"); + } + println!("{label:24} best of {reps}: {best:.2?}"); + } +} diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs index 5ed1c3a3b0..4b8b87616f 100644 --- a/crates/engine/examples/bench_web_palette.rs +++ b/crates/engine/examples/bench_web_palette.rs @@ -18,13 +18,13 @@ fn main() -> Result<(), Box> { let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; let rect = Rect::new(0, 0, w as i32, h as i32); let started = Instant::now(); - let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + let original = optimize(&pixels, w, rect, &settings, None, None, None, 72.0, false)?; println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); let mut edited = settings; edited.color_table = Some(original.color_table); edited.color_table_edits.insert(0, [0, 255, 0]); let started = Instant::now(); - let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + let result = optimize(&pixels, w, rect, &edited, None, None, None, 72.0, false)?; println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); Ok(()) } diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..6f559a5659 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -81,6 +81,8 @@ pub struct ActionsFile { pub actions: Vec, } +pub const MAX_PLAYBACK_STEPS: u64 = 100_000; + /// Actions list and the recording / playback flags. Only `list` is persisted. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -95,9 +97,16 @@ pub struct ActionState { /// Active action names, for cycle detection in engine and desktop playback. #[serde(skip)] pub playback_stack: Vec, + /// Number of steps executed by the current top-level playback tree. + #[serde(skip)] + pub playback_steps: u64, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, + /// Bumped by each `actions.record`, so a background job knows whether the recording it was + /// started in is still the one running when its result lands. + #[serde(skip)] + pub take: u64, } impl ActionState { @@ -121,13 +130,33 @@ pub fn replayable(id: &str) -> bool { && (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) } -/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path -/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder -/// instead. +/// File › Save, Save As and Save a Copy, and File › Export › Export As, recorded by the desktop +/// shell once the file has a path (#2032, #4373, #7207). The shell replays them; batch and +/// droplets write to their destination folder instead. pub fn shell_save_command(id: &str) -> bool { - matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy" | "file.export.exportAs") } +/// The failure for a played Save step whose document `target` (the one active when the step +/// started) is no longer open: a Save Document script event closed it. Playback stops there, so +/// the next step without a document, such as the Close half of an imported Close with Save: Yes, +/// doesn't act on whichever document is active now (#7367). +pub fn save_target_closed(s: &Session, id: &str, target: Option) -> Option { + let target = target.filter(|_| shell_save_command(id))?; + (!s.documents().iter().any(|d| d.doc.id == target)).then(|| { + "the document was closed during the save (by a Save Document script event); playback stopped so later steps don't act on another document".into() + }) +} + +/// Charge one step against the complete nested playback tree. +pub fn consume_playback_step(s: &mut Session) -> bool { + if s.actions.playback_steps >= MAX_PLAYBACK_STEPS { + false + } else { + s.actions.playback_steps += 1; + true + } +} /// View-only menu commands recorded by the desktop shell. Headless playback reports /// them as unsupported instead of silently dropping them; Batch and droplets skip them. pub fn shell_view_command(id: &str) -> bool { @@ -151,18 +180,33 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } -/// The actions that `steps` call through Play Action steps, followed transitively, in list order, -/// so a droplet can run without the Actions panel that made it (#2794). Index references in -/// `steps` and in the copies become names, which resolve without the rest of the list. A target -/// that doesn't resolve is left alone: it fails at playback, as it would have before. +/// The actions that `steps` call through Play Action steps and the branches of Conditional steps, +/// followed transitively, in list order, so a droplet can run without the Actions panel that made +/// it (#2794, #6804). Index references in `steps` and in the copies become names, which resolve +/// without the rest of the list. A target that doesn't resolve is left alone: it fails at +/// playback, as it would have before. pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec { fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut Vec<(usize, usize)>) { - for (_, params) in steps.iter_mut().filter(|(id, _)| id == "actions.play") { - let Ok(i) = resolve(list, params, "actions.play") else { continue }; - if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { - obj.insert("action".into(), json!(action.name)); + for (id, params) in steps.iter_mut() { + match id.as_str() { + "actions.play" => { + let Ok(i) = resolve(list, params, "actions.play") else { continue }; + if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { + obj.insert("action".into(), json!(action.name)); + } + queue.push((i, depth)); + } + // Conditional branches only take names, so there is nothing to rewrite. + "actions.conditional" => { + for key in ["then", "else"] { + let Some(name) = params.get(key).and_then(Value::as_str) else { continue }; + if let Some(i) = list.iter().position(|a| a.name == name) { + queue.push((i, depth)); + } + } + } + _ => {} } - queue.push((i, depth)); } } let mut queue = Vec::new(); @@ -272,6 +316,7 @@ fn record(s: &mut Session, p: &Value) -> Result { s.actions.list.len() - 1 }; s.actions.recording = Some((s.journal.len(), idx)); + s.actions.take = s.actions.take.wrapping_add(1); let name = s.actions.list.get(idx).map(|a| a.name.clone()).unwrap_or_default(); Ok(json!({"action": name, "index": idx})) } @@ -307,16 +352,30 @@ fn stop(s: &mut Session, _p: &Value) -> Result { Ok(json!({"action": action.name, "steps": action.steps.len()})) } +/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an +/// open document and nothing selected (actions often start with it to clear any selection, #6744). +fn noop_step(s: &Session, id: &str) -> bool { + id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none()) +} + fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { let mut ran = 0u64; let mut failed = None; for (i, (id, params)) in action.enabled_steps().skip_while(|(i, _)| *i < from) { + if !consume_playback_step(s) { + failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", MAX_PLAYBACK_STEPS)})); + break; + } if let Some(auth) = s.authorize && let Err(e) = auth(id, params) { failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); break; } + if noop_step(s, id) { + ran += 1; + continue; + } match s.execute(id, params.clone()) { Ok(value) => { if let Some(error) = nested_failure(id, &value) { @@ -338,6 +397,9 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option Result> { flush_recording(s)?; + if s.actions.playing == 0 { + s.actions.playback_steps = 0; + } let recording = s.actions.recording.take(); s.actions.playing = s.actions.playing.saturating_add(1); s.actions.playback_stack.push(action.name.clone()); @@ -348,15 +410,20 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio s.actions.playback_stack.pop(); s.actions.playing = s.actions.playing.saturating_sub(1); if let Some((_, idx)) = recording { - s.actions.recording = Some((s.journal.len(), idx)); - let mut params = json!({"action": action.name}); - if from > 0 { - params["from"] = json!(from); - } - s.journal.push(("actions.play".into(), params)); + restore_recording(s, idx, &action.name, from); } } +/// Resume recording into action `idx` and journal the named call that suspended it. +pub fn restore_recording(s: &mut Session, idx: usize, action: &str, from: usize) { + s.actions.recording = Some((s.journal.len(), idx)); + let mut params = json!({"action": action}); + if from > 0 { + params["from"] = json!(from); + } + s.journal.push(("actions.play".into(), params)); +} + /// Nested playback returns a structured failure even though the command itself succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. pub fn nested_failure(id: &str, value: &Value) -> Option { @@ -474,6 +541,13 @@ fn delete(s: &mut Session, p: &Value) -> Result { } let name = s.actions.list.remove(idx).name; s.actions.touch(); + // Its function key goes with it, or the next action given the same name would inherit it. + let key = format!("{PLAY_SHORTCUT_PREFIX}{name}"); + if s.prefs().shortcuts.contains_key(&key) { + s.edit_prefs(|prefs| { + prefs.shortcuts.remove(&key); + }); + } Ok(json!({"deleted": name})) } @@ -519,8 +593,9 @@ fn toggle(s: &mut Session, p: &Value) -> Result { /// Prefix of the persisted shortcut override that plays a named action (`actions.play:`). pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:"; -/// Rename an action in place. Nested `actions.play` steps that call it by name, and its -/// function-key binding, follow the new name so nothing silently stops working. +/// Rename an action in place. Nested `actions.play` steps and `actions.conditional` branches +/// that call it by name, and its function-key binding, follow the new name so nothing silently +/// stops working. fn rename(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.rename")?; let idx = resolve(&s.actions.list, p, "actions.rename")?; @@ -540,8 +615,15 @@ fn rename(s: &mut Session, p: &Value) -> Result { let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start); let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); for (id, params) in calls { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(old.as_str()) { - params["action"] = json!(name); + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if params.get(*key).and_then(Value::as_str) == Some(old.as_str()) { + params[*key] = json!(name); + } } } if let Some(action) = s.actions.list.get_mut(idx) { @@ -692,6 +774,27 @@ mod tests { s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y) } + #[test] + fn deselect_with_nothing_selected_is_a_noop_in_playback() { + // #6744: imported actions often start with Deselect; with no selection Photoshop plays on. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let before = s.active().unwrap().history.past_len(); + // Outside playback the command stays disabled, like Photoshop's greyed menu item. + assert!(s.execute("select.deselect", json!({})).is_err()); + assert!(s.active().unwrap().doc.selection.is_none()); + assert_eq!(s.active().unwrap().history.past_len(), before, "rejection adds no history step"); + let deselect = crate::commands::find("select.deselect").unwrap(); + assert_eq!((deselect.run)(&mut s, &json!({})).unwrap(), Value::Null); + assert_eq!(s.active().unwrap().history.past_len(), before, "no history step"); + + let steps = vec![("select.deselect".to_string(), json!({})), ("layer.new.layer".to_string(), json!({"name": "A"}))]; + s.actions.list.push(Action::new("Clear", steps)); + let played = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(played["ran"], 2, "{played}"); + assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "A"); + } + #[test] fn unchecked_steps_are_skipped_and_follow_move_and_delete() { let mut s = Session::new(); @@ -769,6 +872,45 @@ mod tests { assert_eq!(s.actions.list[0].name, "Sepia"); } + #[test] + fn rename_follows_conditional_branches() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for name in ["B", "Other"] { + s.actions.list.push(Action { name: name.into(), steps: vec![("layer.new.layer".into(), json!({}))], ..Default::default() }); + } + s.actions.list.push(Action { + name: "Main".into(), + steps: vec![ + ("actions.conditional".into(), json!({"if": "always", "then": "B", "else": "Other"})), + ("actions.conditional".into(), json!({"if": "never", "then": "Other", "else": "B"})), + ], + ..Default::default() + }); + s.execute("actions.rename", json!({"action": "B", "name": "C"})).unwrap(); + let steps = &s.actions.list[2].steps; + assert_eq!(steps[0].1, json!({"if": "always", "then": "C", "else": "Other"})); + assert_eq!(steps[1].1, json!({"if": "never", "then": "Other", "else": "C"})); + let r = s.execute("actions.play", json!({"action": "Main"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + } + + #[test] + fn delete_action_drops_its_function_key_but_deleting_a_step_keeps_it() { + let mut s = Session::new(); + let key = format!("{PLAY_SHORTCUT_PREFIX}Action 1"); + s.actions.list.push(Action::new("Action 1", vec![("layer.new.layer".into(), json!({})), ("layer.new.layer".into(), json!({}))])); + s.edit_prefs(|p| p.shortcuts.insert(key.clone(), "F2".into())); + s.execute("actions.delete", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.prefs().shortcuts.get(&key).map(String::as_str), Some("F2"), "deleting a step keeps the key"); + assert_eq!(s.execute("actions.delete", json!({"action": 0})).unwrap()["deleted"], "Action 1"); + assert!(!s.prefs().shortcuts.contains_key(&key), "the deleted action's function key is gone"); + s.execute("actions.record", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].name, "Action 1"); + assert!(!s.prefs().shortcuts.contains_key(&key), "a new action with the same name starts unbound"); + } + fn record_red(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); s.execute("actions.record", json!({"name": "Red"})).unwrap(); @@ -1025,6 +1167,43 @@ mod tests { } } + /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain + /// layer's recorded copy must still export to .atn, while an artboard's must be refused rather + /// than exported as a copy beside the original. + #[test] + fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Layer copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("actions.record", json!({"name": "Board copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[0]], &[], false).is_ok()); + assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).is_err()); + // Several selected layers are copied together: one artboard among them keeps `inPlace`. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let photo = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Layers copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both layers were copied"); + s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap(); + s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap(); + s.execute("actions.record", json!({"name": "Mixed copy"})).unwrap(); + s.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); + } + #[test] fn named_selection_preserves_modes_and_duplicate_occurrences() { let mut s = Session::new(); @@ -1467,3 +1646,16 @@ mod tests { assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing"); } } + +#[cfg(test)] +mod security_tests { + use super::*; + + #[test] + fn playback_budget_is_shared_by_nested_calls() { + let mut session = Session::new(); + session.actions.playback_steps = MAX_PLAYBACK_STEPS - 1; + assert!(consume_playback_step(&mut session)); + assert!(!consume_playback_step(&mut session)); + } +} diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..f4e0694fa3 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -168,6 +168,44 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() } +/// Most LUT files [`folder_luts`] lists. +const MAX_FOLDER_LUTS: usize = 500; +/// Largest LUT file [`folder_luts`] lists: a 129³ `.cube` (the parser's largest) is about 60 MB. +const MAX_FOLDER_LUT_BYTES: u64 = 64 << 20; + +/// The `.cube`, `.3dl` and `.look` files directly in `dir`, as (file name, path) sorted by name, +/// for Color Lookup's dropdown (Preferences › File Handling › LUT Folder, #6558). An empty, +/// missing or unreadable folder lists nothing; hidden files, subfolders and files too large to be +/// a LUT are skipped. A symlink to a LUT file is listed, as a studio's shared library often is. +/// Contents are only parsed when one is picked, which reports a malformed file. +#[cfg(not(target_arch = "wasm32"))] +pub fn folder_luts(dir: &str) -> Vec<(String, String)> { + let dir = dir.trim(); + let Some(entries) = (!dir.is_empty()).then(|| std::fs::read_dir(dir).ok()).flatten() else { return Vec::new() }; + let mut out: Vec<(String, String)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let ext = name.rsplit_once('.')?.1.to_ascii_lowercase(); + if name.starts_with('.') || !matches!(ext.as_str(), "cube" | "3dl" | "look") { + return None; + } + let path = e.path(); + let meta = std::fs::metadata(&path).ok()?; + (meta.is_file() && meta.len() <= MAX_FOLDER_LUT_BYTES).then_some((name, path.to_str()?.to_string())) + }) + .collect(); + out.sort_by_key(|(name, _)| name.to_lowercase()); + out.truncate(MAX_FOLDER_LUTS); + out +} + +/// No file system on the web: a LUT folder lists nothing there. +#[cfg(target_arch = "wasm32")] +pub fn folder_luts(_: &str) -> Vec<(String, String)> { + Vec::new() +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> Option> { std::fs::read(path).ok() @@ -270,6 +308,8 @@ pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p if !smart { return Ok(false); } + // A pixel or full lock refuses the smart filter, as for `filter.*` on a smart object. + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; // The recorded params are the command's own, minus the engine's private `__kind` key. let mut params = p.clone(); if let Value::Object(m) = &mut params { @@ -296,6 +336,18 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { /// Image › Adjustments run as a smart filter (`image.adjustments.` recorded on a smart /// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run. pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option { + // A Color Lookup filter re-reads its LUT `file`, a path stored in the document: followed only + // as `doc_path` allows, else the filter can't run (like a missing file). + let resolved; + let p = match p.get("file").and_then(Value::as_str).filter(|f| kind == "colorLookup" && !f.is_empty()) { + Some(file) => { + let mut q = p.clone(); + q["file"] = json!(crate::doc_path::resolve(file).ok()?.to_str()?); + resolved = q; + &resolved + } + None => p, + }; let mut out = surf.clone(); if kind == "shadowsHighlights" { let sh = shadows_highlights_params(p); @@ -335,7 +387,7 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { fn replace_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.adjustments.replaceColor"; - let c = crate::commands::color_param(p, "color", s.tools.foreground); + let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?; let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index d831fbf448..312caedc32 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -479,6 +479,26 @@ fn adjustment_smart_filters_take_a_selection_as_their_mask() { assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged"); } +/// #5655: as for `filter.*` smart filters, a pixel or full lock refuses an adjustment smart +/// filter, leaving the layer and the history as they were. +#[test] +fn locked_smart_objects_refuse_adjustment_smart_filters() { + let adjustments = + [("image.adjustments.invert", json!({})), ("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for (cmd, p) in &adjustments { + let mut s = smart_session(); + s.execute("layer.lockLayers", lock.clone()).unwrap(); + let (steps, flat) = (s.active().unwrap().history.past_len(), flatten(&s)); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} added a smart filter to a smart object locked with {lock}: {r:?}"); + assert!(smart_filters_of(&s).is_empty(), "{cmd} {lock}: nothing recorded"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {lock}: no history step"); + assert_eq!(flatten(&s), flat, "{cmd} {lock}: pixels untouched"); + } + } +} + /// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments /// that can't re-render from a source (Replace Color, Match Color) stay off a smart object. #[test] @@ -711,3 +731,23 @@ fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() { } } } + +/// #6558: a LUT folder lists its LUT files by name, case-insensitively, and nothing else. +#[test] +fn folder_luts_lists_lut_files_by_name() { + let dir = tempfile::tempdir().unwrap(); + for f in ["b.cube", "A.3DL", "c.look", "notes.txt", ".hidden.cube", "noext"] { + std::fs::write(dir.path().join(f), "LUT_3D_SIZE 2\n").unwrap(); + } + std::fs::create_dir(dir.path().join("sub.cube")).unwrap(); + // Too large to be a LUT (sparse, so cheap to make). + std::fs::File::create(dir.path().join("huge.cube")).unwrap().set_len(MAX_FOLDER_LUT_BYTES + 1).unwrap(); + // A symlink to a LUT is listed: shared LUT libraries are often linked in. + #[cfg(unix)] + std::os::unix::fs::symlink(dir.path().join("b.cube"), dir.path().join("linked.cube")).unwrap(); + let want: Vec<_> = ["A.3DL", "b.cube", "c.look"].into_iter().chain(cfg!(unix).then_some("linked.cube")).collect(); + let names: Vec<_> = folder_luts(dir.path().to_str().unwrap()).into_iter().map(|(name, _)| name).collect(); + assert_eq!(names, want); + assert!(folder_luts("").is_empty()); + assert!(folder_luts(dir.path().join("missing").to_str().unwrap()).is_empty()); +} diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index c7df82902f..d4cc56b2bd 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -24,6 +24,7 @@ use photocraft_algo::panorama::Layout; use photocraft_algo::pyramid; use photocraft_algo::transform::Interp; +use photocraft_algo::warp::warp_mesh_gray; use photocraft_doc::{Document, Layer, LayerContent, LayerId, LayerMask}; use photocraft_geom::Rect; use photocraft_raster::{Surface, to_rgba}; @@ -107,11 +108,13 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { let surf = l.surface().ok_or(EngineError::NoLayer(ids[i]))?; let b = surf.content_bounds(); let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp)? }; + // Masks warp as masks: one grey channel whose outside keeps its default (reveal-all + // stays revealed), not as colour, which would come back grey + alpha reading 0 outside. let mask = match l.mask.as_ref() { - Some(m) => { - let mb = m.surface.content_bounds(); - Some(if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear)? }) - } + Some(m) => Some(match plane_matrix(pl, (0.0, 0.0)) { + Some(h) => crate::transform_cmds::warp_gray(&m.surface, &h, Interp::Bilinear)?, + None => warp_mesh_gray(&m.surface, &|x, y| pl.forward(x, y), Interp::Bilinear), + }), None => None, }; Ok((ids[i], px, mask)) @@ -148,12 +151,20 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { ) } -/// Native-channel pixels of a layer over `area` (transparent outside its content). -fn pixels(l: &Layer, area: Rect) -> Option<(Vec, photocraft_color::PixelFormat)> { - let s = l.surface()?; - Some((s.read_region(area), s.format())) +/// The most memory Auto-Blend's working buffers may take past the canvas: a bound independent +/// of the layers' own bounding boxes, which a sparse layer (a dot at each far corner) can make +/// arbitrarily large, and of how many layers are blended. +const MAX_BLEND_BYTES: u64 = 4 << 30; + +/// What Auto-Blend's buffers hold per work-area pixel, as f32: each layer's pixels, luminance, +/// alpha and mask weight, plus the merged picture and the depth map. +fn blend_bytes_per_pixel(layers: u64, channels: u64) -> u64 { + layers.saturating_mul(channels.saturating_add(3)).saturating_add(channels.saturating_mul(2)).saturating_mul(4) } +/// Auto-Blend's buffers, for allocation errors. +const BLEND_BUFFERS: &str = "Auto-Blend Layers"; + fn auto_blend(s: &mut Session, p: &Value) -> Result { let cmd = "edit.autoBlendLayers"; let ids = selected_pixel_layers(s); @@ -178,13 +189,24 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); - let mut area = Rect::EMPTY; + let mut union = Rect::EMPTY; + let mut content_px = 0u64; + let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height()); for id in &ids { if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) { - area = area.union(&b); + union = union.union(&b); + content_px = content_px.saturating_add(px_of(&b)); } } - let area = area.intersect(&doc.bounds()); + // The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers + // partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All + // shows the whole panorama. Layers so far apart that their bounds are mostly empty, or + // whose buffers would exceed `MAX_BLEND_BYTES`, keep to the canvas. + let canvas = doc.bounds(); + let channels = ids.iter().filter_map(|id| doc.layer(*id).and_then(|l| l.surface()).map(|s| s.channels() as u64)).max().unwrap_or(4); + let per_px = blend_bytes_per_pixel(ids.len() as u64, channels); + let fits = px_of(&union).saturating_mul(per_px) <= MAX_BLEND_BYTES && px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2); + let area = if fits { union } else { union.intersect(&canvas) }; if area.is_empty() { return Err(EngineError::Other("the selected layers are empty".into())); } @@ -195,9 +217,10 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let mut fmt = None; for id in &ids { let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?; - let (px, f) = pixels(l, area).ok_or(EngineError::NoLayer(*id))?; + let surf = l.surface().ok_or(EngineError::NoLayer(*id))?; + let (px, f) = (crate::allocation::read_region(surf, area, BLEND_BUFFERS)?, surf.format()); let n = f.channels(); - let (mut lv, mut av) = (vec![0.0f32; w * h], vec![0.0f32; w * h]); + let (mut lv, mut av) = (crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?, crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?); for i in 0..w * h { let c = to_rgba(&f, &px[i * n..(i + 1) * n]); lv[i] = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; @@ -225,7 +248,9 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let surfs: Vec = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::>()?; let order = seam_order(&surfs, 0); - let b = seam_blend(&surfs, area, &order, seamless, false, None); + // Seamless Tones and Colors also evens out exposure and colour between the layers (gain + // compensation over their overlaps), as in Photomerge. + let b = seam_blend(&surfs, area, &order, seamless, seamless, None); let weights: Vec> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect(); let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) @@ -258,6 +283,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let label = "Auto-Blend Layers"; let (new_layer, depth_layer) = s.edit(label, |doc, active| { for (id, wt) in ids.iter().zip(&weights) { + // The Background can't have a mask: it becomes a normal layer first, as in Photoshop. + crate::extra_cmds::background_to_layer_for_mask(doc, *id); let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; let mut mask = LayerMask::hide_all(); mask.surface.write_region(area, wt); @@ -384,6 +411,37 @@ mod tests { assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err()); } + /// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its + /// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps. + #[test] + fn auto_align_warps_layer_masks_keeping_their_default() { + for (projection, reveal) in [("reposition", true), ("reposition", false), ("cylindrical", true)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap(); + let a = texture_layer(&mut s, "A", 20, 20); + let b = texture_layer(&mut s, "B", 31, 13); + let (inside, outside) = if reveal { (0.0, 1.0) } else { (1.0, 0.0) }; + s.edit("mask", |doc, _| { + let mut m = if reveal { LayerMask::reveal_all() } else { LayerMask::hide_all() }; + m.surface.fill_rect(Rect::new(100, 100, 110, 110), &[inside]); + doc.layer_mut(b).unwrap().mask = Some(m); + Ok(()) + }) + .unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap(); + let doc = &s.active().unwrap().doc; + let m = doc.layer(b).unwrap().mask.as_ref().unwrap(); + assert_eq!(m.surface.format().channels(), 1, "{projection}: {r}"); + assert_eq!(m.value(250, 200), outside, "{projection} reveal={reveal}: {r}"); + assert_eq!(m.value(5, 5), outside, "{projection} reveal={reveal}: {r}"); + if projection == "reposition" { + // The square moved with the layer (by about (-11, +7)). + assert!((m.value(94, 112) - inside).abs() < 0.01, "{r}"); + } + } + } + #[test] fn auto_blend_panorama_masks_and_seamless_layer() { for depth in [8, 16, 32] { @@ -418,6 +476,155 @@ mod tests { } } + /// #5745: the Background can't take a mask, so Auto-Blend turns it into a normal layer first. + #[test] + fn auto_blend_turns_the_background_into_a_layer() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.background().unwrap().id; + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[bg, right]); + s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.is_background(bg), "the Background became a layer to take its mask"); + assert!(doc.layer(bg).unwrap().mask.is_some()); + s.undo(); + assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background"); + } + + /// Seamless Tones and Colors evens out a 10% exposure step between two panorama layers. + #[test] + fn auto_blend_panorama_seamless_matches_exposure() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + let mut ids = Vec::new(); + for (name, x0, x1, gain) in [("left", 0, 130, 1.0f32), ("right", 70, 200, 1.1)] { + s.execute("layer.new.layer", json!({ "name": name })).unwrap(); + s.edit(name, |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x0, 0, x1, 60), &[0.2 * gain, 0.4 * gain, 0.5 * gain, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + select(&mut s, &ids); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + let (a, b) = (blended.pixel(10, 30), blended.pixel(190, 30)); + // 0.5 vs 0.55 without gain compensation. + assert!((a[2] - b[2]).abs() < 0.015, "{a:?} {b:?}"); + } + + /// #5745: the top layer hangs off the canvas's right edge, so cropped to the canvas it lies + /// wholly over the bottom one. It used to get an all-black mask; now it shows on its side. + #[test] + fn auto_blend_panorama_of_a_layer_the_canvas_crops_to_all_overlap() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + s.execute("edit.autoBlendLayers", json!({"method": "panorama", "seamlessTones": false})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(195, 30), mr.value(195, 30)), (0.0, 1.0)); + for x in 0..200 { + assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "x {x} shows one layer"); + } + } + + /// #5745: a layer Auto-Align left partly off the canvas is masked and blended over all of + /// its bounds, not cut off at the canvas edge. + #[test] + fn auto_blend_panorama_masks_past_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "left"})).unwrap(); + s.edit("l", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 150, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let left = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "right"})).unwrap(); + s.edit("r", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let right = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[left, right]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let ml = doc.layer(left).unwrap().mask.as_ref().unwrap(); + let mr = doc.layer(right).unwrap().mask.as_ref().unwrap(); + assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0)); + assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "off the canvas"); + let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap(); + assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part"); + } + + /// A sparse layer whose bounds are huge (a dot at each far corner) raises its own content + /// estimate, so the extent past the canvas is capped independently: it keeps to the canvas. + #[test] + fn auto_blend_sparse_huge_bounds_keep_to_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "a"})).unwrap(); + s.edit("a", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 150, 200), &[0.2, 0.4, 0.5, 1.0]); + surf.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "b"})).unwrap(); + s.edit("b", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 0, 200, 200), &[0.2, 0.4, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + let b = s.active().unwrap().active_layer.unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap(); + let doc = &s.active().unwrap().doc; + let bb = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap().content_bounds(); + assert!(bb.x1 <= 200 && bb.y1 <= 200, "{bb:?}"); + } + + /// The budget past the canvas counts every layer's buffers, not just the work-area pixels. + #[test] + fn auto_blend_budget_counts_layers_and_channels() { + // Two RGBA layers: 4 bytes × (2 layers × (4 channels + luma, alpha, weight) + merged + depth). + assert_eq!(blend_bytes_per_pixel(2, 4), 88); + assert!(blend_bytes_per_pixel(5, 4) > blend_bytes_per_pixel(2, 4)); + // A 2^27-pixel area would need about 11 GiB for just two RGBA layers: it keeps to the canvas. + assert!((1u64 << 27).saturating_mul(blend_bytes_per_pixel(2, 4)) > MAX_BLEND_BYTES); + assert_eq!(blend_bytes_per_pixel(u64::MAX, 4), u64::MAX, "saturates instead of overflowing"); + } + #[test] fn auto_blend_stack_without_seamless() { let mut s = Session::new(); diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 7c29ecc8ee..5d58cfae88 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -5,6 +5,7 @@ use crate::{EngineError, Result}; #[cfg(test)] thread_local! { static FAIL_NEXT: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FAIL_AFTER: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path. @@ -18,7 +19,7 @@ pub(crate) fn filled(len: usize, value: T, purpose: &str) -> Result(len: usize, purpose: &str) -> Result> { let bytes = len.checked_mul(std::mem::size_of::()).ok_or_else(|| error(purpose, "requested size overflowed"))?; #[cfg(test)] - if FAIL_NEXT.with(|flag| flag.replace(false)) { + if failpoint_hits() { return Err(error(purpose, "not enough memory")); } let mut out = Vec::new(); @@ -30,7 +31,7 @@ pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { /// The test-only failpoint lets command tests prove their transaction boundary. pub(crate) fn checkpoint(purpose: &str) -> Result<()> { #[cfg(test)] - if FAIL_NEXT.with(|flag| flag.replace(false)) { + if failpoint_hits() { return Err(error(purpose, "not enough memory")); } let _ = purpose; @@ -57,6 +58,34 @@ pub(crate) fn fail_next_for_test() { FAIL_NEXT.with(|flag| flag.set(true)); } +/// Fail the fallible allocation or checkpoint after `skip` clean attempts, then stop. +/// `fail_after_for_test(0)` is equivalent to [`fail_next_for_test`]. +#[cfg(test)] +pub(crate) fn fail_after_for_test(skip: usize) { + FAIL_AFTER.with(|flag| flag.set(Some(skip))); +} + +/// Whether a test failpoint trips here: the single-shot flag, or the counted flag +/// once its `skip` attempts have passed cleanly earlier. The shot does not advance the +/// counted flag: a one-shot failure is not itself a clean attempt. +#[cfg(test)] +fn failpoint_hits() -> bool { + if FAIL_NEXT.with(|flag| flag.replace(false)) { + return true; + } + FAIL_AFTER.with(|flag| match flag.get() { + Some(0) => { + flag.set(None); + true + } + Some(rest) => { + flag.set(Some(rest - 1)); + false + } + None => false, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -67,4 +96,27 @@ mod tests { let result = filled::(16, 0, "test image"); assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image"))); } + + #[test] + fn fail_after_trips_only_on_the_counted_attempt() { + fail_after_for_test(2); + assert!(filled::(4, 0, "one").is_ok(), "the counted failpoint lets the first attempts pass cleanly"); + assert!(filled::(4, 0, "two").is_ok()); + assert!(filled::(4, 0, "three").is_err_and(|e| e.to_string().contains("not enough memory for three"))); + // The flag is consumed by the trip: later attempts are clean again. + assert!(filled::(4, 0, "four").is_ok()); + } + + #[test] + fn the_one_shot_does_not_spend_the_counted_failpoint() { + // One-shot and counted flags both armed: the shot trips the first attempt, and that + // failure is not a clean attempt, so the counter still has one to go past (review note + // on #7353). Afterwards the counted attempt trips as usual. + fail_after_for_test(1); + fail_next_for_test(); + assert!(filled::(4, 0, "shot").is_err_and(|e| e.to_string().contains("not enough memory for shot"))); + assert!(filled::(4, 0, "skipped").is_ok(), "the counted attempt has not run out of clean attempts"); + assert!(filled::(4, 0, "counted").is_err_and(|e| e.to_string().contains("not enough memory for counted"))); + assert!(filled::(4, 0, "done").is_ok()); + } } diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..8932295d52 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -231,18 +231,7 @@ pub fn now_iso() -> String { #[cfg(not(target_arch = "wasm32"))] { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + iso_from_unix(secs) } #[cfg(target_arch = "wasm32")] { @@ -250,6 +239,29 @@ pub fn now_iso() -> String { } } +/// `YYYY-MM-DDTHH:MM:SSZ` for Unix seconds `secs` (UTC, proleptic Gregorian). A time outside +/// chrono's year range still converts (the calendar repeats every 400 years, so the instant is +/// moved into range by whole cycles and only the year is carried, matching the old hand-rolled +/// code), never a silent reset to the epoch. chrono is the only calendar logic. +#[cfg(not(target_arch = "wasm32"))] +fn iso_from_unix(secs: i64) -> String { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%SZ")) + } + _ => "1970-01-01T00:00:00Z".to_string(), // unreachable + } +} + fn round4(v: f64) -> f64 { (v * 10_000.0).round() / 10_000.0 } @@ -722,10 +734,12 @@ fn group_index(d: &Document, p: &Value) -> Option { (i < d.measurement.count_groups.len()).then_some(i) } -fn color_of(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } /// Photoshop's colours for new count groups, in order. @@ -755,7 +769,12 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if !at[0].is_finite() || !at[1].is_finite() { return Err(bad("count.add", "coordinates must be finite")); } - let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize); + let gi = p.get("group").and_then(Value::as_u64).map(|v| usize::try_from(v).map_err(|_| bad("count.add", "no such group"))).transpose()?; + // The first marker creates group zero, but an explicit target must never be redirected. + let group_count = doc(s)?.measurement.count_groups.len().max(1); + if gi.is_some_and(|i| i >= group_count) { + return Err(bad("count.add", "no such group")); + } let n = s.edit("Count", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); @@ -840,7 +859,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let color = color_of(p, "color"); + let color = color_of("count.newGroup", p, "color")?; s.edit("New Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); @@ -856,7 +875,9 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { s.edit("Delete Count Group", |d, _| { d.measurement.count_groups.remove(g); let n = d.measurement.count_groups.len(); - d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1)); + // Removing a group before the active one shifts it down a slot. + let a = d.measurement.active_count_group; + d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1)); Ok(()) })?; Ok(count_json(doc(s)?.as_ref())) @@ -887,7 +908,7 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(n) = p.get("name").and_then(Value::as_str) { grp.name = n.to_string(); } - if let Some(c) = color_of(p, "color") { + if let Some(c) = color_of(CMD, p, "color")? { grp.color = c; } if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { @@ -1121,8 +1142,12 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { }; // Bottom-left with a margin; the text sits under (or over) the bar. let margin = (w.min(h) * 0.03).round().max(2.0); - let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0); - let text_h = if show_text { font_size * 1.25 } else { 0.0 }; + // `fontSize` is in points; `type.create` renders it at resolution/72 px per point, so lay out + // the bar and label in those pixels (at 300 ppi, 12 pt is 50 px) (#5754). + let dpi = f64::from(d.resolution_dpi); + let font_px = font_size * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let bar_h = (font_px / 4.0).round().clamp(2.0, h / 4.0); + let text_h = if show_text { font_px * 1.25 } else { 0.0 }; // Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932). let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0); // Likewise pull a bar nearly as wide as the image in from the right edge (#932). @@ -1143,7 +1168,7 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { })?; let mut text_layer = Value::Null; if show_text { - let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size }; + let baseline = if text_top { f64::from(bar.y0) - font_px * 0.3 } else { f64::from(bar.y1) + font_px }; let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label}); if let Some(f) = &font { tp["font"] = json!(f); diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..c1184f2a4c 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_doc::ColorMode; +use photocraft_doc::{ColorMode, LayerContent, LayerId}; fn session(w: u32, h: u32, depth: u32) -> Session { let mut s = Session::new(); @@ -291,6 +291,27 @@ fn count_groups_markers_and_undo() { assert!(s.execute("count.add", json!({"x": 1})).is_err()); } +#[test] +fn deleting_an_earlier_count_group_keeps_the_active_group() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + for _ in 0..3 { + s.execute("count.newGroup", json!({})).unwrap(); + } + s.execute("count.setGroup", json!({"group": 2})).unwrap(); + let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // A later group leaves the active index alone. + let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // Deleting the active last group selects the new last group. + let r = s.execute("count.deleteGroup", json!({})).unwrap(); + assert_eq!(r["activeGroup"], 0); + assert_eq!(r["groups"][0]["name"], "Count Group 2"); +} + #[test] fn log_delete_and_csv_export() { let mut s = session(10, 10, 8); @@ -355,6 +376,27 @@ fn place_scale_marker_builds_one_group_in_one_step() { assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker")); } +#[test] +fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() { + // `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the + // layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar. + for res in [72.0, 300.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap(); + let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap(); + let bar_bottom = r["barRect"][3].as_f64().unwrap(); + let doc = d(&s); + let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") }; + assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points"); + let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi); + let b = layout.bounds().expect("the label has a line"); + let ty = t.transform.m[5]; + let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty); + assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}"); + assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}"); + } +} + #[test] fn place_scale_marker_on_a_short_document_errors_without_panicking() { // A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run` @@ -449,3 +491,64 @@ fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); assert_eq!(s.active().unwrap().history.past_len(), past); } + +#[test] +fn count_add_rejects_unknown_groups_without_changing_the_document() { + for groups in [0, 1, 3] { + for group in [(groups as u64).max(1), u64::MAX] { + let mut s = session(10, 10, 8); + for _ in 0..groups { + s.execute("count.newGroup", json!({})).unwrap(); + } + let before = d(&s); + let history = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err(); + assert!(error.to_string().contains("no such group"), "{error}"); + assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker"); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.journal.len(), journal); + } + } +} + +#[test] +fn count_add_keeps_first_group_creation_and_targets_the_requested_group() { + let mut s = session(10, 10, 8); + // Group zero can be requested before the first marker creates it. + let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap(); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]])); + s.execute("count.newGroup", json!({})).unwrap(); + let before = d(&s).measurement.clone(); + let history = s.active().unwrap().history.past_len(); + let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]])); + assert_eq!(r["groups"][1]["count"], 0); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = d(&s).measurement.clone(); + assert!(s.undo()); + assert_eq!(d(&s).measurement, before); + assert!(s.redo()); + assert_eq!(d(&s).measurement, after); + // Omitting the group still uses the active group. + let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap(); + assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]])); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() { + use super::iso_from_unix; + assert_eq!(iso_from_unix(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_from_unix(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(iso_from_unix(1_791_446_357), "2026-10-08T07:59:17Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(iso_from_unix(253_402_300_800), "10000-01-01T00:00:00Z"); + // Beyond chrono's year range the old hand-rolled code still converted; pin that output + // (never the epoch) in both directions and at the i64 extremes. + assert_eq!(iso_from_unix(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_from_unix(-10_000_000_000_000), "-314918-08-13T06:13:20Z"); + assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z"); + assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z"); +} diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index c3e3e079f7..d9d002ee84 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -9,7 +9,7 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Artboard, ArtboardBackground, Document, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -92,10 +92,12 @@ fn preset_size(name: &str) -> Option<(u32, u32)> { } fn background_param(p: &Value, cmd: &str) -> Result> { - let custom = p.get("color").map(|_| { - let c = color_param(p, "color", [1.0; 4]); - ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3])) - }); + let custom = if p.get("color").is_some() { + let c = color_param(cmd, p, "color", [1.0; 4])?; + Some(ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3]))) + } else { + None + }; Ok(match p.get("background").and_then(Value::as_str) { None => custom, Some("white") => Some(ArtboardBackground::White), @@ -181,6 +183,20 @@ pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<()> { Ok(()) } +/// Record a Duplicate's `inPlace` only when a copy (the selection after it) is an artboard: for +/// any other layer it changes nothing, and Photoshop's Duplicate can't express it, so a recorded +/// Alt-drag copy of a plain layer must stay exportable to .atn (#7200). +pub(crate) fn recorded_duplicate(s: &Session, mut p: Value) -> Value { + let Some(st) = s.active() else { return p }; + if st.selected_layers().into_iter().any(|id| st.doc.layer(id).is_some_and(|l| l.artboard().is_some())) { + return p; + } + if let Some(fields) = p.as_object_mut() { + fields.remove("inPlace"); + } + p +} + /// Grow the canvas so every artboard fits. Photoshop's artboard documents grow in every /// direction: a board left of or above the origin shifts the whole document (boards, their /// contents, guides, paths…) right/down by its overhang, then the canvas grows to show it. An @@ -424,6 +440,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result { }; let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32; let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" }; + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (nid, rect) = s.edit(label, |doc, active| { if (sx, sy) != (0, 0) { crate::image_cmds::translate_doc(doc, cmd, sx, sy)?; @@ -438,7 +455,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result { let nid = if duplicate { let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?; let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0)); - let copy = crate::commands::layer_copy(doc, id)?; + let copy = crate::commands::layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), copy); // Grow the canvas first, so shapes on the board re-render uncut where they land. let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32; @@ -504,6 +521,9 @@ pub fn artboard_document(doc: &Document, id: LayerId) -> Result one.layer_comps.clear(); one.last_applied_comp = None; one.last_document_state = None; + // Paths, notes, slices and Count/Ruler marks move with the board. This also shifts + // `one.guides`, but they are rebuilt below from the source's, so nothing moves twice. + crate::canvas_geom::transform_doc_marks(&mut one, &Affine::translate(f64::from(dx), f64::from(dy))); let shift = |v: &[f64], o: i32, len: u32| v.iter().map(|g| g - f64::from(o)).filter(|g| *g > 0.0 && *g < f64::from(len)).collect::>(); one.guides.vertical = shift(&doc.guides.vertical, r.x0, r.width()); one.guides.horizontal = shift(&doc.guides.horizontal, r.y0, r.height()); diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index c7695f228e..b7ad123f9d 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -85,6 +85,34 @@ fn from_layers_and_group_then_compose_clips() { } } +/// #7945: one layer selected inside an artboard aligns to that artboard, as in Photoshop, not to +/// the canvas (which would move it out of the board). The board itself still aligns to the canvas. +#[test] +fn a_lone_layer_in_an_artboard_aligns_to_the_artboard() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let red = add_square(&mut s, Rect::new(10, 10, 20, 20), [1.0, 0.0, 0.0, 1.0]); + let board = LayerId(s.execute("layer.new.artboardFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.artboard.set", json!({"x": 140, "y": 20, "width": 40, "height": 50})).unwrap(); + let bounds = |s: &Session, id: LayerId| crate::layer_multi_cmds::layer_bounds(doc(s).layer(id).unwrap()).unwrap(); + assert_eq!(bounds(&s, red), Rect::new(140, 20, 150, 30), "{depth}"); + + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.align.rightEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, red), Rect::new(170, 20, 180, 30), "{depth}: to the board's right edge"); + s.execute("layer.align.verticalCenters", json!({})).unwrap(); + assert_eq!(bounds(&s, red), Rect::new(170, 40, 180, 50), "{depth}: to the board's centre"); + // An explicit `to` still wins. + s.execute("layer.align.topEdges", json!({"to": "canvas"})).unwrap(); + assert_eq!(bounds(&s, red).y0, 0, "{depth}"); + + // The artboard alone has no board around it: its contents align to the canvas. + s.execute("layer.select", json!({"layer": board.0})).unwrap(); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, red).x0, 0, "{depth}"); + } +} + #[test] fn clear_artboard_guides_only_inside() { let mut s = session(8); @@ -331,6 +359,33 @@ fn export_rejects_a_board_too_far_from_the_origin() { assert_eq!(one.artboards()[0].2.rect, Rect::new(0, 0, 50, 50)); } +/// Artboards to Files moves paths, notes, slices and Count/Ruler marks with the board, like its +/// layers and guides, so they land on the same pixels in the exported file. +#[test] +fn artboard_export_moves_paths_notes_slices_and_marks_with_the_board() { + use photocraft_doc::{CountGroup, NamedPath, Note, Path, Slice, Subpath}; + let mut s = session(8); + s.execute("layer.new.artboard", json!({"x": 200, "y": 100, "width": 50, "height": 50})).unwrap(); + s.edit("marks", |d, _| { + d.paths.push(NamedPath { name: "P".into(), path: Path::new(vec![Subpath::polygon(&[(210.0, 110.0), (240.0, 110.0), (240.0, 140.0)])]), psd_raw: None }); + d.work_path = Some(Path::new(vec![Subpath::polygon(&[(205.0, 105.0), (215.0, 105.0), (215.0, 115.0)])])); + d.notes.push(Note { position: [220.0, 120.0], popup: [220.0, 120.0, 260.0, 160.0], ..Default::default() }); + d.slices.list.push(Slice { id: 1, rect: Rect::new(210, 110, 230, 130), ..Default::default() }); + d.measurement.count_groups.push(CountGroup { points: vec![[225.0, 125.0]], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let one = artboard_document(doc(&s), doc(&s).artboards()[0].0).unwrap().unwrap(); + let k = &one.paths[0].path.subpaths[0].knots[0].anchor; + assert_eq!((k.x, k.y), (10.0, 10.0)); + let w = &one.work_path.as_ref().unwrap().subpaths[0].knots[0].anchor; + assert_eq!((w.x, w.y), (5.0, 5.0)); + assert_eq!(one.notes[0].position, [20.0, 20.0]); + assert_eq!(one.notes[0].popup, [20.0, 20.0, 60.0, 60.0]); + assert_eq!(one.slices.list[0].rect, Rect::new(10, 10, 30, 30)); + assert_eq!(one.measurement.count_groups[0].points, vec![[25.0, 25.0]]); +} + /// #1531: a board 40×40 at (10, 10) holding a red square at (10..30)², the artboard active. fn board_with_square(s: &mut Session) -> LayerId { add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); @@ -585,3 +640,24 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() { assert_eq!(doc(&s).size, before); assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err()); } + +/// Adding a duplicated artboard beside another appends "copy" to its name only while the Layers panel option is on. +#[test] +fn add_beside_duplicate_honors_panel_copy_name_preference() { + for add_copy in [false, true] { + let mut s = session(8); + let id = board_with_square(&mut s); + let name = doc(&s).layer(id).unwrap().name.clone(); + s.edit_prefs(|prefs| { + prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"addCopyToCopiedLayersAndGroups": add_copy})); + }); + let before = doc(&s).clone(); + let result = s.execute("layer.artboard.addBeside", json!({"layer": id.0, "side": "bottom", "duplicate": true})).unwrap(); + let copy = LayerId(result["layer"].as_u64().unwrap()); + let expected = if add_copy { before.copy_name(&name) } else { name }; + assert_eq!(doc(&s).layer(copy).unwrap().name, expected); + assert!(red_at(&s, 15, 155), "the copied artboard retains its contents"); + s.undo(); + assert_eq!(doc(&s), &before); + } +} diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..f166c239b3 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -13,7 +13,9 @@ //! work; tool selections and other steps with no document effect are imported switched off. //! //! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which -//! `commands::layer_param` resolves in whatever document the action plays on (#219). +//! `commands::layer_param` resolves in whatever document the action plays on (#219). A switch +//! to another open document (`select` of `Dcmn` by offset) becomes `document.activate +//! {"offset"}`, the tab that many places from the active one (#7103). use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; @@ -121,6 +123,7 @@ enum Target { Property(String), Class, Offset(i32), + Index(u32), Other, } @@ -131,6 +134,7 @@ fn target(r: &[ReferenceItem]) -> Target { Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)), Some(ReferenceItem::Class(_)) => Target::Class, Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset), + Some(ReferenceItem::Index { index, .. }) => Target::Index(*index), _ => Target::Other, } } @@ -207,10 +211,11 @@ fn class_ref(cls: &str) -> D { } /// The `null` reference for a command's `layer` param: by name, else the target layer. -fn layer_ref(p: &Value) -> D { +fn layer_ref(p: &Value) -> Option { match p.get("layer") { - Some(Value::String(n)) => name_ref("Lyr ", n), - _ => target_ref("Lyr "), + None | Some(Value::Null) => Some(target_ref("Lyr ")), + Some(Value::String(n)) => Some(name_ref("Lyr ", n)), + _ => None, } } @@ -389,6 +394,8 @@ enum Kind { Pct, Ang, Long, + /// An integer field whose value must survive conversion without rounding or saturation. + ExactLong, Dbl, Bool, /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs. @@ -402,11 +409,13 @@ enum Kind { /// One descriptor key ↔ one command param. struct F(&'static str, &'static str, Kind); +/// Import mapped descriptor fields, leaving absent values to command defaults. +/// Return `None` when a present enum, blend mode or colour cannot be represented. fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { let mut m = Map::new(); for F(key, param, kind) in fields { let v = match kind { - Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from), + Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::ExactLong | Kind::Dbl => get_num(d, key).map(Value::from), Kind::Bool => get_bool(d, key).map(Value::from), Kind::Enum(_, table) => match get_enum(d, key) { Some(e) => Some(Value::from(lookup(table, &e)?)), @@ -428,6 +437,8 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { Some(m) } +/// Export supplied mapped params, omitting absent values to preserve event defaults. +/// Return `None` for unsupported values; `ExactLong` also rejects rounding or saturation. fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { for F(key, param, kind) in fields { let Some(v) = p.get(param) else { continue }; @@ -435,7 +446,23 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { Kind::Px => px(v.as_f64()?), Kind::Pct => pct(v.as_f64()?), Kind::Ang => ang(v.as_f64()?), - Kind::Long => long(v.as_f64()?), + // A value outside the descriptor's 32-bit integer refuses the step rather than being + // clamped to another value (an Add Noise seed above i32::MAX replayed as i32::MAX, #7287). + Kind::Long => { + let n = v.as_f64()?.round(); + if !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + return None; + } + long(n) + } + Kind::ExactLong => { + let number = v.as_f64()?; + let integer = number as i32; + if f64::from(integer) != number { + return None; + } + D::Integer(integer) + } Kind::Dbl => D::Double(v.as_f64()?), Kind::Bool => D::Boolean(v.as_bool()?), Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), @@ -565,6 +592,9 @@ fn curves_to(p: &Value) -> Option { Some(desc("Crvs").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) } +/// The hue key of a Hue/Saturation adjustment (`Hst2`): Photoshop's `H ` (#8302). +const HUE_KEY: &str = "H "; + const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; fn hue_sat_from(d: &Descriptor) -> Option { @@ -575,8 +605,11 @@ fn hue_sat_from(d: &Descriptor) -> Option { for e in get_list(d, "Adjs").unwrap_or(&[]) { let D::Descriptor(e) = e else { return None }; let mut m = Map::new(); - for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { - if let Some(v) = get_num(e, key) { + // Photoshop's hue key is `H ` (stringID "hue", #8302); PhotoCraft wrote `Hue ` before, + // so its own older files still read. + let hue = get_num(e, HUE_KEY).or_else(|| get_num(e, "Hue ")); + for (param, v) in [("hue", hue), ("saturation", get_num(e, "Strt")), ("lightness", get_num(e, "Lght"))] { + if let Some(v) = v { m.insert(param.into(), v.into()); } } @@ -606,25 +639,28 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { } } } - for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + for (key, param) in [(HUE_KEY, "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); } Some(e) } +// Exporters leave out a param the step doesn't set only where Photoshop plays the missing key +// as PhotoCraft's default (checked against Photoshop: Fill opacity and mode, Crop delete, +// Selective Color method, Channel Mixer monochrome, Paint Bucket contiguous; Gradient Map +// reverse and dither, which Photoshop itself leaves out when off). Elsewhere they +// write PhotoCraft's default, as Photoshop plays a missing key differently (Color Balance +// preserve luminosity off, Canvas Size anchored top left, Apply Image Normal) or refuses the +// step (Paint Bucket without a tolerance). fn hue_sat_to(p: &Value) -> Option { + let d = desc("HStr").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))); let mut entries = vec![D::Descriptor(hue_entry(p, None)?)]; for (i, name) in HUE_RANGES.iter().enumerate() { if let Some(r) = p.get(*name).filter(|v| v.is_object()) { entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?)); } } - Some( - desc("HStr") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))) - .with("Adjs", D::List(entries)), - ) + Some(d.with("Adjs", D::List(entries))) } fn triple(d: &Descriptor, k: &str) -> Option { @@ -694,12 +730,17 @@ fn channel_mixer_to(p: &Value) -> Option { d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3))))); } } - Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) + if let Some(m) = jbool(p, "monochrome") { + d = d.with("Mnch", D::Boolean(m)); + } + Some(d) } +/// Photoshop's `Clrs` colour-range enumeration. Yellows is `Ylws` (`Yllw` is the yellow ink +/// key); earlier PhotoCraft builds wrote `Yllw`, which import still reads (#6745). const SELECTIVE_RANGES: &[(&str, &str)] = &[ ("Rds", "reds"), - ("Yllw", "yellows"), + ("Ylws", "yellows"), ("Grns", "greens"), ("Cyns", "cyans"), ("Bls", "blues"), @@ -716,7 +757,8 @@ fn selective_from(d: &Descriptor) -> Option { } for e in get_list(d, "ClrC").unwrap_or(&[]) { let D::Descriptor(e) = e else { return None }; - let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?; + let range = get_enum(e, "Clrs")?; + let name = lookup(SELECTIVE_RANGES, if range == "Yllw" { "Ylws" } else { &range })?; let v = |k: &str| get_num(e, k).unwrap_or(0.0); out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")])); } @@ -724,21 +766,46 @@ fn selective_from(d: &Descriptor) -> Option { } fn selective_to(p: &Value) -> Option { - let mut list = Vec::new(); - for (ps, name) in SELECTIVE_RANGES { + // Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form + // `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and + // gives the channels it changes. Writing only the arrays dropped the scalar form's values, and + // the step exported as an adjustment that changes nothing (#7672). + let mut rows: Vec> = vec![None; SELECTIVE_RANGES.len()]; + for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) { if let Some(a) = p.get(*name).and_then(Value::as_array) { let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + *row = Some([v(0), v(1), v(2), v(3)]); + } + } + if let Some(named) = jstr(p, "colors") { + let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?; + let row = rows.get_mut(i)?; + let mut v = row.unwrap_or([0.0; 4]); + for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) { + // A channel that is given but is not a number is an error in the command + // (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written + // either: ignoring it would export a different adjustment than the one recorded. + // `null` counts as absent there, and so here. + match p.get(key) { + None | Some(Value::Null) => {} + Some(_) => *slot = jnum(p, key)?, + } + } + *row = Some(v); + } + let mut list = Vec::new(); + for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) { + if let Some(v) = row { list.push(D::Descriptor( - desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))), + desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])), )); } } - Some( - desc("SlcC") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" })) - .with("ClrC", D::List(list)), - ) + let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom")); + if let Some(m) = jstr(p, "method") { + d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" })); + } + Some(d.with("ClrC", D::List(list))) } /// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. @@ -787,15 +854,18 @@ fn gradient_map_to(p: &Value) -> Option { .with("Intr", D::Double(4096.0)) .with("Clrs", D::List(stops?)) .with("Trns", transparency); - Some( - desc("GdMp") - .with("Grad", D::Descriptor(grad)) - .with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false))) - .with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))), - ) + let mut d = desc("GdMp").with("Grad", D::Descriptor(grad)); + if let Some(r) = jbool(p, "reverse") { + d = d.with("Rvrs", D::Boolean(r)); + } + if let Some(dd) = jbool(p, "dither") { + d = d.with("Dthr", D::Boolean(dd)); + } + Some(d) } -/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and +/// An adjustment: its Photoshop class (usually also the direct event's code; Black & White and +/// Photo Filter run by name), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { class: &'static str, @@ -821,7 +891,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to }, adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]), adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), - adj_fields!("PhtF", "photoFilter", &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]), + Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to }, adj_fields!("BlnW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, @@ -832,6 +902,29 @@ fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end()) } +const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]; + +/// A `PhtF` step whose params carry a `filter` preset without an explicit `color`: the descriptor +/// form is colour-only (Photoshop stores no preset key — the `phfl` notes in `crates/io` conclude +/// the same from its CS2 oracles), so the resolved colour must be written, or re-imported playback +/// falls back to the default warming filter. With both present, the explicit colour wins, as when +/// running the command. An unknown preset id keeps the old export (no colour) instead of failing. +fn photo_filter_to(p: &Value) -> Option { + let mut p = p.clone(); + if p.get("color").is_none() { + let hex = p.get("filter").and_then(Value::as_str).and_then(preset_color); + if let (Some(hex), Some(o)) = (hex, p.as_object_mut()) { + o.insert("color".into(), hex.into()); + } + } + fields_to(&p, PHOTO_FILTER_FIELDS, desc("PhtF")) +} + +/// The preset id's resolved `#rrggbb`, or none (no id given, or an unknown one). +fn preset_color(id: &str) -> Option { + crate::adjust_params::PHOTO_FILTERS.iter().find(|f| f.0 == id).map(|(_, _, rgb)| format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])) +} + fn adjustment_by_suffix(suffix: &str) -> Option<&'static Adjustment> { ADJUSTMENTS.iter().find(|a| a.suffix == suffix) } @@ -872,6 +965,11 @@ struct Event { macro_rules! ev { ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => { + ev!($code, $name, $label, $id, $fields, unless | _ | false) + }; + // `unless`: params the descriptor can't carry, so such a step isn't exported (it would replay + // differently) rather than losing them. + ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr, unless $skip:expr) => { Event { code: $code, name: $name, @@ -881,11 +979,76 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| fields_to(p, $fields, desc("null")), + to: |_, p| if pixel_target(p) && !($skip)(p) && holds_its_settings($id, p) { fields_to(p, $fields, desc("null")) } else { None }, } }; } +/// Minimum / Maximum with Preserve: Roundness. The descriptor here maps the radius only, and +/// Photoshop's encoding of the shape isn't verified, so such a step is not exported: it would +/// replay as the default Squareness (#7233). +fn preserves_roundness(p: &Value) -> bool { + p.get("preserve").and_then(Value::as_str) == Some("roundness") +} + +/// Whether any of the boolean `flags` is on in `p` (present, not null and not `false`). +fn flag_set(p: &Value, flags: &[&str]) -> bool { + flags.iter().any(|f| p.get(*f).is_some_and(|v| !v.is_null() && *v != Value::Bool(false))) +} + +/// Select > Modify with "Apply effect at canvas bounds": the descriptor has no field for it, so such a +/// step is not exported (it would replay without the option). +fn applies_at_canvas_bounds(p: &Value) -> bool { + flag_set(p, &["applyAtCanvasBounds"]) +} + +/// Settings a mapped event has no field for, by command: `(param, the value the event plays as)`. +/// Photoshop's event has nowhere to put these, so a step that sets one cannot be written: it +/// would import as a different edit, silently. Such a step is refused, the way [`pixel_target`] +/// refuses a filter aimed at a mask -- and no key Photoshop does not define is invented for it, +/// which the clean-room rule forbids and which would make the file wrong in another reader. +/// +/// A step that leaves the setting at the value the event plays as exports as before. +/// How a setting the event cannot carry behaves once the step is replayed without it. Which of +/// the two a setting takes follows the command's own reading of it, not the parameter spec. +enum Lost { + /// Replay uses this value, so only a step already at it survives. Numbers and strings are + /// both compared as written, since the commands below read these settings as written. + ReplaysAs(&'static str), + /// The command reads only this one spelling as anything other than what replay does, so every + /// other value survives -- a differently cased one included, because it too plays as replay's. + DiffersAt(&'static str), +} + +const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ + // Crst carries ClSz only; replay seeds the pattern from 0 (#7668). + ("filter.pixelate.crystallize", &[("seed", Lost::ReplaysAs("0"))]), + // RdlB carries Amnt, BlrM and BlrQ; replay spins or zooms about the middle of the layer. + ("filter.blur.radialBlur", &[("centerX", Lost::ReplaysAs("0.5")), ("centerY", Lost::ReplaysAs("0.5"))]), + // Mnm /Mxm carry Rds only; replay keeps squareness, and so does the command for anything but + // the exact string "roundness" -- see `preserve` in filters.rs. + ("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]), + ("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]), +]; + +/// Whether every setting of `p` that its event cannot hold would play the same without it +/// (see [`UNHOLDABLE`]). +fn holds_its_settings(id: &str, p: &Value) -> bool { + let Some((_, settings)) = UNHOLDABLE.iter().find(|(cmd, _)| *cmd == id) else { return true }; + settings.iter().all(|(param, lost)| match (p.get(param), lost) { + (None | Some(Value::Null), _) => true, + (Some(v), Lost::ReplaysAs(played)) => v.as_str() == Some(played) || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)), + (Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling), + }) +} + +/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's +/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such +/// a step can't be exported faithfully. +fn pixel_target(p: &Value) -> bool { + p.get("target").is_none_or(|t| t.as_str() == Some("pixels")) +} + const INTERPOLATION: &[(&str, &str)] = &[ ("Bcbc", "bicubic"), ("Blnr", "bilinear"), @@ -915,7 +1078,10 @@ const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center" const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; -const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +/// Smart Blur quality. Photoshop's codes come first, so they are what an export writes (measured in +/// Photoshop: `Hgh `/`Mdm `/`Low ` play back as its fallback, low quality). The old codes after +/// them still read, for actions PhotoCraft wrote before. +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("SBQL", "low"), ("SBQM", "medium"), ("SBQH", "high"), ("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; @@ -982,6 +1148,14 @@ fn pattern_desc(p: &Value) -> Option { Some(t) } +/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command. +const MASK_USING: &[(&str, &str)] = &[ + ("RvlA", "layer.layerMask.revealAll"), + ("HdAl", "layer.layerMask.hideAll"), + ("RvlS", "layer.layerMask.revealSelection"), + ("HdSl", "layer.layerMask.hideSelection"), +]; + 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` @@ -1003,13 +1177,25 @@ fn make_from(d: &Descriptor) -> Mapped { } return step("layer.duplicate", Value::Object(p)); } + // 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(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.renameLayer", json!({"name": n}))); + v.push(("layer.setProps", json!({"name": n}))); } return steps(v); } @@ -1086,8 +1272,18 @@ fn make_from(d: &Descriptor) -> Mapped { if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { p.insert("name".into(), n.into()); } - let id = if get_ref(d, "From").is_some() { "layer.groupLayers" } else { "layer.new.group" }; - step(id, Value::Object(p)) + match get_ref(d, "From") { + // A named source layer stays the source (#7236); a target reference and an + // index/id source (our exporter never writes those) keep the old implicit + // import: the group lands on the current selection at playback. + Some(r) => { + if let Some(t) = layer_target(r) { + p.extend(t); + } + step("layer.groupLayers", Value::Object(p)) + } + None => step("layer.new.group", Value::Object(p)), + } } _ => Mapped::Unsupported { harmless: false }, } @@ -1103,6 +1299,18 @@ fn make_to(id: &str, p: &Value) -> Option { }; Some(match id { "layer.new.layer" => { + // The descriptor carries the name, opacity and mode only. A step that also clips to + // the layer below, labels the layer, fills it with the neutral colour or places it + // below the active layer would replay as a different layer, so it isn't exported + // (strict export fails, a partial one skips it: #7502, #7512, #7513, #7514). + let set = |key: &str, default: Value| p.get(key).is_some_and(|v| !v.is_null() && *v != default); + if set("clipToPrevious", Value::Bool(false)) + || set("color", json!("none")) + || set("fillNeutral", Value::Bool(false)) + || set("position", json!("above")) + { + return None; + } let mut u = with_name("Lyr "); if let Some(o) = jnum(p, "opacity") { u = u.with("Opct", pct(o * 100.0)); @@ -1114,7 +1322,9 @@ fn make_to(id: &str, p: &Value) -> Option { } "layer.new.group" => desc("null").with("null", class_ref("layerSection")).with("Usng", D::Descriptor(with_name("layerSection"))), "layer.groupLayers" => { - desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")).with("Usng", D::Descriptor(with_name("layerSection"))) + // An explicit source layer goes in by name like every other layer reference; a + // step without one keeps the target-layer form (#7236). + desc("null").with("null", class_ref("layerSection")).with("From", layer_ref(p)?).with("Usng", D::Descriptor(with_name("layerSection"))) } "layer.new.layerViaCopy" | "layer.new.layerViaCut" => { let mut d = desc("null").with("Nw ", D::Descriptor(desc("Lyr "))).with("Usng", enm("ArSl", "Slct")); @@ -1125,8 +1335,10 @@ fn make_to(id: &str, p: &Value) -> Option { } "layer.newFillLayer.solidColor" => { let mut t = desc("solidColorLayer"); - if let Some(c) = jstr(p, "color").and_then(color_desc) { - t = t.with("Clr ", D::Descriptor(c)); + // The descriptor's colour is opaque RGB: a colour with alpha, or one it can't parse, + // would replay as the default black (#7507). + if let Some(c) = p.get("color").filter(|v| !v.is_null()) { + t = t.with("Clr ", D::Descriptor(c.as_str().and_then(color_desc)?)); } desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } @@ -1143,7 +1355,7 @@ fn make_to(id: &str, p: &Value) -> Option { if jstr(p, "document") != Some("new") { return None; } - let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)?); if let Some(n) = jstr(p, "documentName") { d = d.with("Nm ", text(n)); } @@ -1152,6 +1364,14 @@ fn make_to(id: &str, p: &Value) -> Option { } d } + "layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection" => { + // Photoshop adds the mask to the target layer only. + if p.get("layer").is_some() { + return None; + } + let at = D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]); + desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?)) + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1163,10 +1383,19 @@ fn make_to(id: &str, p: &Value) -> Option { } n = n.with("Wdth", px(jnum(p, "width").unwrap_or(1920.0))).with("Hght", px(jnum(p, "height").unwrap_or(1080.0))); n = n.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: jnum(p, "resolution").unwrap_or(72.0) }); - let fill = match jstr(p, "background") { - Some("transparent") => "Trns", - Some("backgroundColor") => "BckC", - _ => "Wht ", + // `Fl ` is white, transparent or Photoshop's own background colour: black, a hex + // colour or a given `backgroundColor` would replay as something else (#7503). + if p.get("backgroundColor").is_some_and(|v| !v.is_null()) { + return None; + } + let fill = match p.get("background").filter(|v| !v.is_null()) { + None => "Wht ", + Some(b) => match b.as_str()? { + "white" => "Wht ", + "transparent" => "Trns", + "backgroundColor" => "BckC", + _ => return None, + }, }; n = n.with("Fl ", enm("Fl ", fill)).with("Dpth", long(jnum(p, "depth").unwrap_or(8.0))); desc("null").with("Nw ", D::Descriptor(n)) @@ -1262,6 +1491,11 @@ fn set_from(d: &Descriptor) -> Mapped { } steps(v) } + // 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})), + None => unsupported, + }, (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1303,7 +1537,15 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { Some((a, t)) => match (a.from)(t) { - Some(p) => step("layer.setAdjustment", p), + // `kind` names the adjustment so Save Actions can write the class back. + // Not on an empty set (Photoshop's "reset to default"): params with any key + // merge, none reset. + Some(mut p) => { + if let Some(o) = p.as_object_mut().filter(|o| !o.is_empty()) { + o.insert("kind".into(), json!(a.suffix)); + } + step("layer.setAdjustment", p) + } None => unsupported, }, None => unsupported, @@ -1320,6 +1562,14 @@ fn set_to(id: &str, p: &Value) -> Option { "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), + // The descriptor loads a plain new selection: inverting it or combining it with the current + // one (add, subtract, intersect) would be lost (#7506). + "select.loadSelection" + if p.get("invert").is_some_and(|v| *v != Value::Bool(false) && !v.is_null()) + || p.get("operation").is_some_and(|v| *v != json!("new") && !v.is_null()) => + { + return None; + } "select.loadSelection" => match jstr(p, "channel")? { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), "targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])), @@ -1343,12 +1593,37 @@ fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", quick_mask_ref()) } + "tools.setColors" => { + // One colour per Photoshop step: a step that sets both (or any other key) doesn't + // export rather than lose one. + if p.as_object().is_some_and(|o| o.keys().any(|k| k != "foreground" && k != "background")) { + return None; + } + let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) { + (Some(f), None) => ("FrgC", f), + (None, Some(b)) => ("BckC", b), + _ => return None, + }; + desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: code(key) }])) + .with("T ", D::Descriptor(color_desc(hex)?)) + } "select.rect" => { + match jstr(p, "mode") { + None | Some("replace") => {} + Some(_) => return None, + } let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; sel(D::Descriptor(desc(cls).with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))) } "layer.setProps" | "layer.renameLayer" | "layer.setLabelColor" => { + // Only these keys map onto the `Lyr ` descriptor; locks, clipping, Advanced Blending + // channels or an explicit `layers` list would be dropped (#7196, #7246). + const MAPPED: [&str; 7] = ["layer", "name", "opacity", "fill", "blend", "visible", "color"]; + if p.as_object().is_some_and(|m| m.iter().any(|(k, v)| !v.is_null() && !MAPPED.contains(&k.as_str()))) { + return None; + } let mut t = desc("Lyr "); if let Some(n) = jstr(p, "name") { t = t.with("Nm ", text(n)); @@ -1371,7 +1646,7 @@ fn set_to(id: &str, p: &Value) -> Option { if t.items.is_empty() { return None; } - desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) + desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t)) } "layer.new.layerFromBackground" => { let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); @@ -1406,6 +1681,8 @@ fn select_from(d: &Descriptor) -> Mapped { match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), + // Shift-click: select everything between the active layer and this one. + Some("addToSelectionContinuous") => p["mode"] = json!("range"), _ => {} } step("layer.select", p) @@ -1435,20 +1712,36 @@ fn select_from(d: &Descriptor) -> Mapped { } } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + // A menu item chosen while recording (`Mn`): the View menu's zoom items replay. + (Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) { + Some(id) => none(id), + None => Mapped::Unsupported { harmless: false }, + }, + // Switching to another open document: its offset from the active one, -1 for the previous + // tab and 1 for the next (a template action copies from one and pastes into the other, #7103). + (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), _ => Mapped::Unsupported { harmless: false }, } } +/// `MnIt` menu-item codes ↔ the shell's view commands. +const MENU_ITEMS: &[(&str, &str)] = &[("FtOn", "view.fitOnScreen"), ("ActP", "view.actualPixels"), ("ZmIn", "view.zoomIn"), ("ZmOt", "view.zoomOut")]; + fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); Some(match id { "layer.select" => { - let mut d = desc("null").with("null", name_ref("Lyr ", jstr(p, "layer")?)); + if p.get("occurrence").is_some() { + return None; + } + let name = jstr(p, "name").or_else(|| jstr(p, "layer"))?; + let mut d = desc("null").with("null", name_ref("Lyr ", name)); match jstr(p, "mode") { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")), + Some("range") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelectionContinuous")), Some(_) => return None, } d.with("MkVs", D::Boolean(false)) @@ -1469,7 +1762,15 @@ fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), - _ => return None, + // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. + "document.activate" if p.get("document").is_none_or(Value::is_null) => { + let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) + } + _ => { + let item = lookup_back(MENU_ITEMS, id)?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: code("MnIt"), value: code(item) }])) + } }) } @@ -1577,7 +1878,12 @@ fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } } -fn close_to(_: &str, _: &Value) -> Option { +/// A Close of an explicit document has no mapping here: written as a plain Close it would +/// close the active document on playback, so it is left to `skipUnsupported`. +fn close_to(_: &str, p: &Value) -> Option { + if p.get("document").is_some_and(|d| !d.is_null()) { + return None; + } Some(desc("null").with("Svng", enm("YsN ", "N "))) } @@ -1585,7 +1891,9 @@ fn close_to(_: &str, _: &Value) -> Option { /// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything /// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. fn transform_from(d: &Descriptor) -> Mapped { - let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // No reference (a smart object replaced in place, `replaceLayer`) means the target layer. + let target_layer = [ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]; + let r: &[ReferenceItem] = get_ref(d, "null").unwrap_or(&target_layer); let unsupported = Mapped::Unsupported { harmless: false }; let mut p = Map::new(); if let Some(o) = get_obj(d, "Ofst") { @@ -1608,18 +1916,28 @@ fn transform_from(d: &Descriptor) -> Mapped { if d.get("Skew").is_some() || d.get("Usng").is_some() { return unsupported; } - let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + // A whole-pixel move is a plain `layer.translate`; a fractional one needs `edit.transform` + // to keep its sub-pixel placement and interpolation. + let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; + // The resampling applies to a selection as much as to a layer (#7515). + let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")); match (ref_class(r).as_deref(), target(r)) { - (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), + (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { + if let Some(i) = interpolation { + p.insert("interpolation".into(), i.into()); + } + step("select.transformSelection", Value::Object(p)) + } (Some("Lyr"), _) => { let Some(mut lp) = layer_target(r) else { return unsupported }; if geometry_only { - lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); - lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).into()); return step("layer.translate", Value::Object(lp)); } lp.extend(p); - if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + if let Some(i) = interpolation { lp.insert("interpolation".into(), i.into()); } step("edit.transform", Value::Object(lp)) @@ -1629,11 +1947,23 @@ fn transform_from(d: &Descriptor) -> Mapped { } fn transform_to(id: &str, p: &Value) -> Option { - if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if ["quad", "corners", "matrix", "rect"].iter().any(|k| given(k)) { + return None; + } + // The descriptor scales and rotates about the centre (`FTcs` average) without skew or warp: + // another reference point, a skew or a warp would replay as a different transform (#7516, + // #7517, #7518, #7519). + let nonzero = |k: &str| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)); + let off_centre = p.get("reference").is_some_and(|v| !v.is_null() && *v != json!("center")); + if nonzero("skewX") || nonzero("skewY") || off_centre || ["warp", "style", "mesh", "grid"].iter().any(|k| given(k)) { return None; } let target = match id { - "edit.transform" => layer_ref(p), + // The `Trnf` descriptor has no field for a mask, Quick Mask or alpha channel target; a + // layer reference would transform the layer's pixels instead. + "edit.transform" if !pixel_target(p) => return None, + "edit.transform" => layer_ref(p)?, "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), _ => return None, }; @@ -1718,6 +2048,15 @@ fn apply_image_from(d: &Descriptor) -> Mapped { } fn apply_image_to(_: &str, p: &Value) -> Option { + // The optional blending mask has no descriptor mapping: refuse it rather than drop it, + // which would widen the edit to the whole layer. + let mask_channel = match p.get("mask") { + Some(m) => m.get("channel"), + None => p.get("maskChannel"), + }; + if mask_channel.is_some_and(|c| !(c.is_null() || c == "none" || c == "")) { + return None; + } let source = p.get("source").cloned().unwrap_or(json!({})); let channel = jstr(&source, "channel").unwrap_or("composite"); let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; @@ -1814,7 +2153,7 @@ fn export_to(_: &str, p: &Value) -> Option { fn delete_to(id: &str, p: &Value) -> Option { Some(match id { "edit.clear" => desc("null"), - "layer.delete" => desc("null").with("null", layer_ref(p)), + "layer.delete" => desc("null").with("null", layer_ref(p)?), "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), _ => return None, }) @@ -1838,11 +2177,21 @@ fn visibility_from(d: &Descriptor, id: &str) -> Mapped { } fn visibility_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", D::List(vec![layer_ref(p)]))) + Some(desc("null").with("null", D::List(vec![layer_ref(p)?]))) } fn move_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // The selection outline nudged. + if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Property(p) if p == "fsel") { + return match get_obj(d, "T ") { + Some(t) if matches!(t.get("Hrzn"), Some(D::UnitFloat { unit: PX, .. })) => match (get_num(t, "Hrzn"), get_num(t, "Vrtc")) { + (Some(dx), Some(dy)) => step("select.transformSelection", json!({"dx": dx.round(), "dy": dy.round()})), + _ => Mapped::Unsupported { harmless: false }, + }, + _ => Mapped::Unsupported { harmless: false }, + }; + } if ref_class(r).as_deref() != Some("Lyr") { return Mapped::Unsupported { harmless: false }; } @@ -1878,6 +2227,8 @@ fn move_from(d: &Descriptor) -> Mapped { Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", Target::Offset(1) => "layer.arrange.bringForward", Target::Offset(-1) => "layer.arrange.sendBackward", + // Index 0 is the bottom of the stack. + Target::Index(0) => "layer.arrange.sendToBack", _ => return Mapped::Unsupported { harmless: false }, }; step(id, Value::Object(p)) @@ -1904,7 +2255,7 @@ fn move_to(id: &str, p: &Value) -> Option { "layer.arrange.sendBackward" => ord("Prvs"), _ => return None, }; - Some(desc("null").with("null", layer_ref(p)).with("T ", t)) + Some(desc("null").with("null", layer_ref(p)?).with("T ", t)) } fn duplicate_from(d: &Descriptor) -> Mapped { @@ -1941,8 +2292,13 @@ fn duplicate_to(id: &str, p: &Value) -> Option { if id == "layer.duplicate" && jstr(p, "document") == Some("new") { return None; } + // Photoshop's Duplicate has no in-place flag: a plain one would place the artboard copy beside + // the original on playback. + if id == "layer.duplicate" && jbool(p, "inPlace") == Some(true) { + return None; + } let mut d = match id { - "layer.duplicate" => desc("null").with("null", layer_ref(p)), + "layer.duplicate" => desc("null").with("null", layer_ref(p)?), "image.duplicate" => { desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Frst") }])) } @@ -1957,6 +2313,20 @@ fn duplicate_to(id: &str, p: &Value) -> Option { Some(d) } +/// Only a comp name in the active document is portable across action playback. +fn apply_comp_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null") { + Some([ReferenceItem::Name { class, name }]) if trimmed(&class.class_id) == "compsClass" => { + step("layerComp.apply", json!({"comp": name.to_string_lossy()})) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn apply_comp_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))) +} + fn play_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { Some(Target::Name(n)) => step("actions.play", json!({"action": n})), @@ -1966,6 +2336,10 @@ fn play_from(d: &Descriptor) -> Mapped { fn play_to(_: &str, p: &Value) -> Option { let name = jstr(p, "action")?; + // The Play Action descriptor written here carries no starting step, so a call from a later step is not a whole call. + if p.get("from").is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)) { + return None; + } let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; if let Some(set) = jstr(p, "set") { items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); @@ -1986,6 +2360,10 @@ fn fill_from(d: &Descriptor) -> Mapped { match get_enum(d, "Usng").as_deref() { Some("FrgC") | None => {} Some("Pttn") => p["contents"] = json!("pattern"), + Some("Clr") => match get_obj(d, "Clr ").and_then(color_hex) { + Some(c) => p["color"] = json!(c), + None => return Mapped::Unsupported { harmless: false }, + }, Some(_) => return Mapped::Unsupported { harmless: false }, } if let Some(t) = get_num(d, "Tlrn") { @@ -2032,11 +2410,30 @@ fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); - d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + // `source` is the command's older name for `contents`. + d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") { + // `Pttn` names no pattern and holds no placement: a chosen pattern, a scale, an angle + // or a phase would replay with the current pattern at its defaults (#7543, #7544, + // #7545, #7546). + let num = |k: &str, default: f64| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(default)); + let phase = p.get("phase").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| a.iter().any(|c| c.as_f64() != Some(0.0)))); + if p.get("pattern").is_some_and(|v| !v.is_null()) || num("scale", 100.0) || num("angle", 0.0) || phase { + return None; + } + d.with("Usng", enm("FlCn", "Pttn")) + } else { + // An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `. + match bucket_color(p.get("color"))? { + Some(c) => d.with("Usng", enm("FlCn", "Clr ")).with("Clr ", D::Descriptor(c)), + None => d.with("Usng", enm("FlCn", "FrgC")), + } + }; if let Some(o) = jnum(p, "opacity") { d = d.with("Opct", pct(o)); } - d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(c) = jbool(p, "contiguous") { + d = d.with("Cntg", D::Boolean(c)); + } if let Some(m) = jbool(p, "sampleAllLayers") { d = d.with("Mrgd", D::Boolean(m)); } @@ -2047,13 +2444,51 @@ fn fill_to(id: &str, p: &Value) -> Option { if contents == "color" { d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?)); } - d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?)); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + if let Some(m) = jstr(p, "mode") { + d = d.with("Md ", enm("BlnM", blend_ps(m)?)); + } if let Some(t) = jbool(p, "preserveTransparency") { d = d.with("PrsT", D::Boolean(t)); } Some(d) } +/// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or +/// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the +/// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1). +fn bucket_color(v: Option<&Value>) -> Option> { + let rgba = match v { + Some(Value::Array(a)) if a.len() >= 3 => { + let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); + [*c.first()?, *c.get(1)?, *c.get(2)?, c.get(3).copied().unwrap_or(1.0)] + } + Some(Value::String(h)) => { + let h = h.trim_start_matches('#'); + let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f64::from(v) / 255.0); + let (Some(r), Some(g), Some(b)) = (c(0), c(2), c(4)) else { return Some(None) }; + [r, g, b, c(6).unwrap_or(1.0)] + } + _ => return Some(None), + }; + if rgba[3] != 1.0 || !rgba.iter().all(|v| (0.0..=1.0).contains(v)) { + return None; + } + Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0)))) +} + +/// Preserve the command's effective integer width instead of rounding its input again. +fn stroke_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) { + return None; + } + let mut params = if p.is_null() { Map::new() } else { p.as_object()?.clone() }; + params.insert("width".into(), Value::from(crate::extra_cmds::stroke_width(p))); + fields_to(&Value::Object(params), STROKE, desc("null")) +} + const STROKE: &[F] = &[ F("Wdth", "width", Kind::Long), F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), @@ -2081,7 +2516,11 @@ fn rotate_from(d: &Descriptor) -> Mapped { } else if targets_layer(d) { return Mapped::Unsupported { harmless: false }; } else { - return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + let mut p = json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }}); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + p["interpolation"] = i.into(); + } + return step("image.rotation.arbitrary", p); }; none(if targets_layer(d) { layer } else { doc }) } @@ -2101,7 +2540,20 @@ fn rotate_to(id: &str, p: &Value) -> Option { if layer { d = d.with("null", target_ref("Lyr ")); } - Some(d.with("Angl", ang(angle))) + d = d.with("Angl", ang(angle)); + if id == "image.rotation.arbitrary" + && let Some(i) = p.get("interpolation") + { + // The command resamples with nearest, bilinear or bicubic; any other value can't be + // carried faithfully, so refuse rather than replay with a different interpolation. + let i = match i.as_str()? { + "nearestNeighbor" => "nearest", + i @ ("bicubic" | "bilinear" | "nearest") => i, + _ => return None, + }; + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) } fn flip_from(d: &Descriptor) -> Mapped { @@ -2321,15 +2773,79 @@ fn image_size_from(d: &Descriptor) -> Mapped { step("image.imageSize", Value::Object(p)) } -fn image_size_to(_: &str, p: &Value) -> Option { +/// Shadows/Highlights (`adaptCorrect`, #5830): the shadow and highlight tones are `shadowMode` +/// and `highlightMode` objects (amount and width in %, radius in px), then the black and white +/// clips (%), midtone contrast (`center`) and colour correction. Each key is read as its char ID +/// or its string ID, whichever the file used; absent keys keep the command's defaults. +const SHADOWS_HIGHLIGHTS_TONES: [(&str, &str, &str); 2] = [("sdwM", "shadowMode", "shadow"), ("hglM", "highlightMode", "highlight")]; +const SHADOWS_HIGHLIGHTS_TONE: [(&str, &str, &str); 3] = [("Amnt", "amount", "Amount"), ("Wdth", "width", "Tone"), ("Rds ", "radius", "Radius")]; +const SHADOWS_HIGHLIGHTS: [(&str, &str, &str); 4] = + [("BlcC", "blackClip", "blackClip"), ("WhtC", "whiteClip", "whiteClip"), ("Cntr", "center", "midtone"), ("ClrC", "colorCorrection", "color")]; + +fn shadows_highlights_from(d: &Descriptor) -> Mapped { + let mut m = Map::new(); + for (code, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let Some(t) = get_obj(d, code).or_else(|| get_obj(d, name)) else { continue }; + for (code, name, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = get_num(t, code).or_else(|| get_num(t, name)) { + m.insert(format!("{prefix}{suffix}"), Value::from(v)); + } + } + } + for (code, name, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = get_num(d, code).or_else(|| get_num(d, name)) { + m.insert(param.into(), Value::from(v)); + } + } + step("image.adjustments.shadowsHighlights", Value::Object(m)) +} + +fn shadows_highlights_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); - if let Some(pc) = jnum(p, "percent") { + for (_, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let mut t = desc("adaptCorrectTones"); + for (_, key, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = jnum(p, &format!("{prefix}{suffix}")) { + t = t.with(key, if suffix == "Radius" { long(v) } else { pct(v) }); + } + } + d = d.with(name, D::Descriptor(t)); + } + for (_, key, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = jnum(p, param) { + d = d.with(key, if matches!(param, "midtone" | "color") { long(v) } else { D::Double(v) }); + } + } + Some(d) +} + +fn image_size_to(_: &str, p: &Value) -> Option { + // Resolution only with Resample on imports as keep-print-size; a command without it cannot be told apart. + if jnum(p, "resolution").is_some() + && jnum(p, "width").is_none() + && jnum(p, "height").is_none() + && jnum(p, "percent").is_none() + && jstr(p, "resample").is_some_and(|r| r != "none") + && jbool(p, "keepPrintSize") != Some(true) + { + return None; + } + let mut d = desc("null"); + // `resample: "none"` changes the resolution only and the command ignores any size then. The + // descriptor has no "none" (it is the absence of `Intr`), so writing the ignored size would + // turn the step into a pixel resize on playback (#7189). + let resolution_only = jstr(p, "resample") == Some("none"); + if resolution_only && jnum(p, "resolution").is_none() { + // Nothing left to record: the step changes nothing. + return None; + } + if let Some(pc) = jnum(p, "percent").filter(|_| !resolution_only) { d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); } - if let Some(w) = jnum(p, "width") { + if let Some(w) = jnum(p, "width").filter(|_| !resolution_only) { d = d.with("Wdth", px(w)); } - if let Some(h) = jnum(p, "height") { + if let Some(h) = jnum(p, "height").filter(|_| !resolution_only) { d = d.with("Hght", px(h)); } if let Some(r) = jnum(p, "resolution") { @@ -2344,7 +2860,13 @@ fn image_size_to(_: &str, p: &Value) -> Option { fn crop_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); if let Some(t) = get_obj(d, "To ") { - 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 { + // Pixels only, like Canvas Size: a side in percent or points needs the document's size + // or resolution and must not be played as pixels. + let side = |k: &str| match t.get(k) { + Some(D::UnitFloat { unit: PX, value }) if value.is_finite() => Some(*value), + _ => None, + }; + let (Some(top), Some(left), Some(bottom), Some(right)) = (side("Top "), side("Left"), side("Btom"), side("Rght")) else { return Mapped::Unsupported { harmless: false }; }; p.insert("x".into(), left.round().into()); @@ -2362,6 +2884,11 @@ fn crop_from(d: &Descriptor) -> Mapped { } fn crop_to(_: &str, p: &Value) -> Option { + // The W x H x Resolution output options have no field here: refuse rather than export a plain + // rectangle crop that keeps the old size and resolution on replay. + if ["targetWidth", "targetHeight", "resolution"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) { + return None; + } let mut d = desc("null"); if let (Some(x), Some(y), Some(w), Some(h)) = (jnum(p, "x"), jnum(p, "y"), jnum(p, "width"), jnum(p, "height")) { d = d.with("To ", D::Descriptor(desc("Rctn").with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))); @@ -2369,7 +2896,10 @@ fn crop_to(_: &str, p: &Value) -> Option { if let Some(a) = jnum(p, "angle") { d = d.with("Angl", ang(a)); } - Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true)))) + if let Some(del) = jbool(p, "deleteCroppedPixels") { + d = d.with("Dlt ", D::Boolean(del)); + } + Some(d) } fn stop_from(d: &Descriptor) -> Mapped { @@ -2484,14 +3014,18 @@ fn levels_event_to(id: &str, p: &Value) -> Option { }) } -/// A direct adjustment event (Image › Adjustments › …). +/// A direct adjustment event (Image › Adjustments › …). The command id is `image.adjustments.` +/// unless it is given. macro_rules! adjustment_event { ($code:literal, $class:literal, $name:literal, $label:literal) => { + adjustment_event!($code, $class, $name, $label, concat!("image.adjustments.", $name)) + }; + ($code:literal, $class:literal, $name:literal, $label:literal, $id:expr) => { Event { code: $code, name: $name, label: $label, - ids: &[concat!("image.adjustments.", $name)], + ids: &[$id], from: |d| match adjustment_by_class($class) { Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), None => Mapped::Unsupported { harmless: false }, @@ -2503,6 +3037,21 @@ macro_rules! adjustment_event { const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; +/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds. +fn has_refine_strokes(p: &Value) -> bool { + p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) +} + +/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a +/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not +/// exported here either: it exports as `smartBrushWorkspace`. +fn refine_edge_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) { + return None; + } + fields_to(p, REFINE_EDGE, desc("null")) +} + /// Select and Mask (`refineSelectionEdge`). Smart Radius, decontamination and the output follow /// Photoshop's; the edge itself is PhotoCraft's own matting, so results differ in detail. const REFINE_EDGE: &[F] = &[ @@ -2543,6 +3092,174 @@ fn gallery_to(id: &str, p: &Value) -> Option { fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code))) } +/// Select and Mask as Photoshop CC 2015.5 and later record it (`smartBrushWorkspace`); the +/// older Refine Edge event is [`REFINE_EDGE`], which is also what Select and Mask exports as. +const SMART_BRUSH: &[F] = &[ + F("smartBrushRadius", "radius", Kind::Px), + F("smartBrushUseSmartRadius", "smartRadius", Kind::Bool), + F("smartBrushSmooth", "smooth", Kind::Long), + F("smartBrushFeather", "feather", Kind::Px), + F("smartBrushContrast", "contrast", Kind::Pct), + F("smartBrushShiftEdge", "shiftEdge", Kind::Pct), + F("smartBrushDecontaminate", "decontaminate", Kind::Bool), + F("smartBrushDeconAmount", "amount", Kind::Pct), + F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)), + F("sampleAllLayers", "sampleAllLayers", Kind::Bool), +]; + +/// Select › Color Range's `Clrs` presets ↔ `select.colorRange`'s `select`. +const COLOR_RANGE: &[(&str, &str)] = &[ + ("Rds", "reds"), + ("Ylws", "yellows"), + ("Grns", "greens"), + ("Cyns", "cyans"), + ("Bls", "blues"), + ("Mgnt", "magentas"), + ("Hghl", "highlights"), + ("Mdtn", "midtones"), + ("Shdw", "shadows"), + ("OtOf", "outOfGamut"), + ("skinTone", "skinTones"), +]; + +/// The Color Range keys the converter reads (or that don't change a preset's result: +/// `colorModel` is the model of sampled colours). +const COLOR_RANGE_KEYS: &[&str] = &[ + "Clrs", + "Fzns", + "Invr", + "colorModel", + "shadowsFuzziness", + "shadowsUpperLimit", + "midtonesFuzziness", + "midtonesLowerLimit", + "midtonesUpperLimit", + "highlightsFuzziness", + "highlightsLowerLimit", +]; + +/// Color Range (`ClrR`) with a preset. Sampled Colors records Lab minimum and maximum colours, +/// which `select.colorRange` (eyedropper points on the image) can't reproduce, and an unknown +/// key (Detect Faces, Localized Color Clusters…) could change the result: both stay +/// unsupported. +fn color_range_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if d.items.iter().any(|(k, _)| !COLOR_RANGE_KEYS.contains(&trimmed(k).as_str())) { + return unsupported; + } + let Some(preset) = get_enum(d, "Clrs").and_then(|c| lookup(COLOR_RANGE, &c)) else { return unsupported }; + let mut p = Map::new(); + p.insert("select".into(), preset.into()); + let key = |suffix: &str| get_num(d, &format!("{preset}{suffix}")); + match preset { + "skinTones" => { + if let Some(f) = get_num(d, "Fzns") { + p.insert("fuzziness".into(), f.into()); + } + } + "shadows" | "midtones" | "highlights" => { + if let Some(f) = key("Fuzziness") { + p.insert("fuzziness".into(), f.into()); + } + let range = match (preset, key("LowerLimit"), key("UpperLimit")) { + ("shadows", _, hi) => hi.map(Value::from), + ("highlights", lo, _) => lo.map(Value::from), + (_, Some(lo), Some(hi)) => Some(json!([lo, hi])), + (_, None, None) => None, + _ => return unsupported, + }; + if let Some(r) = range { + p.insert("tonalRange".into(), r); + } + } + _ => {} + } + if get_bool(d, "Invr") == Some(true) { + p.insert("invert".into(), true.into()); + } + step("select.colorRange", Value::Object(p)) +} + +fn color_range_to(_: &str, p: &Value) -> Option { + let preset = jstr(p, "select")?; + if p.as_object()?.keys().any(|k| !matches!(k.as_str(), "select" | "fuzziness" | "tonalRange" | "invert")) { + return None; + } + let mut d = desc("null").with("Clrs", enm("Clrs", lookup_back(COLOR_RANGE, preset)?)); + let tonal = matches!(preset, "shadows" | "midtones" | "highlights"); + if let Some(f) = jnum(p, "fuzziness") { + match preset { + "skinTones" => d = d.with("Fzns", long(f)), + _ if tonal => d = d.with(&format!("{preset}Fuzziness"), long(f)), + _ => {} + } + } + if let Some(r) = p.get("tonalRange") { + match preset { + "shadows" => d = d.with("shadowsUpperLimit", long(r.as_f64()?)), + "highlights" => d = d.with("highlightsLowerLimit", long(r.as_f64()?)), + "midtones" => { + let a = r.as_array()?; + d = d.with("midtonesLowerLimit", long(a.first()?.as_f64()?)).with("midtonesUpperLimit", long(a.get(1)?.as_f64()?)); + } + _ => return None, + } + } + if jbool(p, "invert") == Some(true) { + d = d.with("Invr", D::Boolean(true)); + } + Some(d) +} + +/// Create / Release Clipping Mask (`groupEvent` / `ungroup`) on the target or a named layer. +fn clipping_from(d: &Descriptor, id: &str) -> Mapped { + match get_ref(d, "null") { + None => none(id), + Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) { + Some(p) => step(id, Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + }, + Some(_) => Mapped::Unsupported { harmless: false }, + } +} + +fn clipping_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", layer_ref(p)?)) +} + +/// A clipping mask event: `clipping_from` / `clipping_to` with one command id. +macro_rules! clipping_event { + ($code:literal, $name:literal, $label:literal, $id:literal) => { + Event { code: $code, name: $name, label: $label, ids: &[$id], from: |d| clipping_from(d, $id), to: clipping_to } + }; +} + +const RADIAL_BLUR_FIELDS: &[F] = + &[F("Amnt", "amount", Kind::Long), F("BlrM", "method", Kind::Enum("BlrM", RADIAL_METHOD)), F("BlrQ", "quality", Kind::Enum("BlrQ", RADIAL_QUALITY))]; + +fn radial_blur_from(d: &Descriptor) -> Mapped { + match fields_from(d, RADIAL_BLUR_FIELDS) { + Some(m) => step("filter.blur.radialBlur", Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn radial_blur_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) { + return None; + } + // Photoshop's Radial Blur has no center parameter and always blurs from the center (0.5, 0.5). + // An explicit center must match 0.5 exactly; any non-default center cannot be faithfully exported. + let is_default_center = |k: &str| match p.get(k) { + None | Some(Value::Null) => true, + Some(v) => v.as_f64() == Some(0.5), + }; + if !is_default_center("centerX") || !is_default_center("centerY") { + return None; + } + fields_to(p, RADIAL_BLUR_FIELDS, desc("null")) +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. const EVENTS: &[Event] = &[ // Filters. @@ -2569,9 +3286,17 @@ const EVENTS: &[Event] = &[ ev!("Mdn ", "median", "Median", "filter.noise.median", &[F("Rds ", "radius", Kind::Px)]), ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), + // Photoshop records Emboss's angle, height and amount as plain integers (#7946). + ev!( + "Embs", + "emboss", + "Emboss", + "filter.stylize.emboss", + &[F("Angl", "angle", Kind::Long), F("Hght", "height", Kind::Long), F("Amnt", "amount", Kind::Long)] + ), ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), - ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), - ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!( "SmrB", "smartBlur", @@ -2584,21 +3309,15 @@ const EVENTS: &[Event] = &[ F("SmBM", "mode", Kind::Enum("SmBM", SMART_BLUR_MODE)) ] ), - ev!( - "RdlB", - "radialBlur", - "Radial Blur", - "filter.blur.radialBlur", - &[F("Amnt", "amount", Kind::Long), F("BlrM", "method", Kind::Enum("BlrM", RADIAL_METHOD)), F("BlrQ", "quality", Kind::Enum("BlrQ", RADIAL_QUALITY))] - ), + Event { code: "RdlB", name: "radialBlur", label: "Radial Blur", ids: &["filter.blur.radialBlur"], from: radial_blur_from, to: radial_blur_to }, ev!("Blr ", "blur", "Blur", "filter.blur.blur", &[]), ev!("BlrM", "blurMore", "Blur More", "filter.blur.blurMore", &[]), ev!("Shrp", "sharpen", "Sharpen", "filter.sharpen.sharpen", &[]), ev!("ShrM", "sharpenMore", "Sharpen More", "filter.sharpen.sharpenMore", &[]), ev!("ShrE", "sharpenEdges", "Sharpen Edges", "filter.sharpen.sharpenEdges", &[]), ev!("HghP", "highPass", "High Pass", "filter.other.highPass", &[F("Rds ", "radius", Kind::Px)]), - ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)]), - ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)]), + ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness), + ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness), ev!( "Ofst", "offset", @@ -2606,10 +3325,18 @@ const EVENTS: &[Event] = &[ "filter.other.offset", &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), - ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), - ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), + ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), + ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). + Event { + code: "", + name: "adaptCorrect", + label: "Shadows/Highlights", + ids: &["image.adjustments.shadowsHighlights"], + from: shadows_highlights_from, + to: shadows_highlights_to, + }, Event { code: "Lvls", name: "levels", @@ -2620,47 +3347,18 @@ const EVENTS: &[Event] = &[ }, adjustment_event!("Crvs", "Crvs", "curves", "Curves"), adjustment_event!("HStr", "HStr", "hueSaturation", "Hue/Saturation"), - Event { - code: "BrgC", - name: "brightnessEvent", - label: "Brightness/Contrast", - ids: &["image.adjustments.brightnessContrast"], - from: |d| match adjustment_by_class("BrgC") { - Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), - None => Mapped::Unsupported { harmless: false }, - }, - to: |_, p| adjustment_by_class("BrgC").and_then(|a| (a.to)(p)), - }, + adjustment_event!("BrgC", "BrgC", "brightnessEvent", "Brightness/Contrast", "image.adjustments.brightnessContrast"), adjustment_event!("Thrs", "Thrs", "threshold", "Threshold"), adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"), adjustment_event!("Invr", "Invr", "invert", "Invert"), adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), - adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), - Event { - code: "BlnW", - name: "blackAndWhite", - label: "Black & White", - ids: &["image.adjustments.blackWhite"], - from: |d| match adjustment_by_class("BlnW") { - Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), - None => Mapped::Unsupported { harmless: false }, - }, - to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), - }, + // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). + adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), + adjustment_event!("", "BlnW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"), adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), - Event { - code: "GdMp", - name: "gradientMapEvent", - label: "Gradient Map", - ids: &["image.adjustments.gradientMap"], - from: |d| match adjustment_by_class("GdMp") { - Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), - None => Mapped::Unsupported { harmless: false }, - }, - to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)), - }, + adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), @@ -2760,6 +3458,10 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.channelMixer", "layer.newAdjustmentLayer.selectiveColor", "layer.newAdjustmentLayer.gradientMap", + "layer.layerMask.revealAll", + "layer.layerMask.hideAll", + "layer.layerMask.revealSelection", + "layer.layerMask.hideSelection", "file.new", "file.newFromClipboard", ], @@ -2778,6 +3480,7 @@ const EVENTS: &[Event] = &[ "select.rect", "select.lasso", "select.editInQuickMaskMode", + "tools.setColors", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2803,6 +3506,11 @@ const EVENTS: &[Event] = &[ "channel.target", "edit.undo", "edit.redo", + "view.fitOnScreen", + "view.actualPixels", + "view.zoomIn", + "view.zoomOut", + "document.activate", ], from: select_from, to: select_to, @@ -2831,7 +3539,7 @@ const EVENTS: &[Event] = &[ from: move_from, to: move_to, }, - ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + ev!("FltI", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), Event { code: "MrgV", name: "mergeVisible", @@ -2841,11 +3549,24 @@ const EVENTS: &[Event] = &[ to: merge_visible_to, }, ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]), + clipping_event!("Ungr", "ungroup", "Release Clipping Mask", "layer.releaseClippingMask"), ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]), ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]), ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]), ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]), + clipping_event!("GrpL", "groupEvent", "Create Clipping Mask", "layer.createClippingMask"), + ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]), + ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]), + ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]), + Event { + code: "", + name: "collapseAllGroupsEvent", + label: "Collapse All Groups", + ids: &["layer.setExpanded"], + from: |_| step("layer.setExpanded", json!({"all": true, "expanded": false})), + to: |_, p| (jbool(p, "all") == Some(true) && jbool(p, "expanded") == Some(false)).then(|| desc("null")), + }, ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]), Event { code: "Algn", @@ -2864,17 +3585,42 @@ const EVENTS: &[Event] = &[ }, // Selection. ev!("Invs", "inverse", "Inverse", "select.inverse", &[]), - ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]), - ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]), - ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]), - ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]), + ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)], unless applies_at_canvas_bounds), + ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)], unless applies_at_canvas_bounds), ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), - ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE), + // `refineSelectionEdge` has no Sample All Layers, so a step with it exports as + // `smartBrushWorkspace` instead of dropping it. + Event { + code: "", + name: "refineSelectionEdge", + label: "Select and Mask", + ids: &["select.refineEdge"], + from: |d| match fields_from(d, REFINE_EDGE) { + Some(m) => step("select.refineEdge", Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: refine_edge_to, + }, + ev!("", "smartBrushWorkspace", "Select and Mask", "select.refineEdge", SMART_BRUSH, unless has_refine_strokes), + ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]), + Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to }, // Edit. Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, - ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), + Event { + code: "Strk", + name: "stroke", + label: "Stroke", + ids: &["edit.stroke"], + from: |d| match fields_from(d, STROKE) { + Some(m) => step("edit.stroke", Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: stroke_to, + }, ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), ev!("cut ", "cut", "Cut", "edit.cut", &[]), @@ -2914,12 +3660,25 @@ const EVENTS: &[Event] = &[ ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, + Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; +/// Four-char codes PhotoCraft once exported for events Photoshop only runs by name (or by +/// another code), with the name they stand for. Measured in Photoshop: `executeAction` with +/// these codes fails ("the command is not currently available"), and `Fltn` is the +/// `flatness` property, not the flatten event. Import keeps accepting them so actions saved +/// before still load; export never writes them. +const LEGACY_CODES: &[(&str, &str)] = + &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter")]; + fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + find(&id).or_else(|| match item { + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), + AtnEvent::Name(_) => None, + }) } /// The event `id` exports as: the first event listing it whose converter accepts the params @@ -2952,7 +3711,7 @@ pub struct Imported { fn photoshop_step(item: &AtnItem) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); - (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor})) + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor})) } /// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings @@ -2987,7 +3746,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) let (new_steps, enabled) = match mapped { Mapped::Steps(s) => (s, item.enabled), Mapped::Unsupported { harmless } => { - unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() }); + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() }); (vec![photoshop_step(item)], item.enabled && !harmless) } }; @@ -3015,8 +3774,8 @@ pub fn import(bytes: &[u8]) -> std::result::Result { let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); - if a.function_key > 0 && !a.shift && !a.command { - out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + if a.function_key > 0 { + out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); } out.actions.push(action); } @@ -3038,25 +3797,72 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { let event = jstr(params, "event")?; let event = match event.as_bytes() { [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), - _ => AtnEvent::Name(event.to_string()), + _ => AtnEvent::name(event), }; let descriptor = match jstr(params, "descriptor") { Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), None => None, }; - return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); + return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").as_bytes().to_vec(), descriptor, ..Default::default() }); } let mut params = params.clone(); if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } let (e, descriptor) = event_and_descriptor(id, ¶ms)?; - let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; - Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) + let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; + Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }) +} + +/// Photoshop applies only the rename when a Set mixes it with opacity or blend mode (#8304). +/// Write each layer property separately, renaming last so named references still resolve. +fn export_items(id: &str, params: &Value, set: &str) -> Option> { + let item = export_item(id, params, set)?; + if id != "layer.setProps" { + return Some(vec![item]); + } + let descriptor = item.descriptor.as_ref()?; + let properties = get_obj(descriptor, "T ")?; + if properties.items.len() <= 1 { + return Some(vec![item]); + } + let target = descriptor.get("null")?; + let mut items = Vec::with_capacity(properties.items.len()); + for (key, value) in properties.items.iter().filter(|(k, _)| !k.is("Nm ")).chain(properties.items.iter().filter(|(k, _)| k.is("Nm "))) { + let mut property = properties.clone(); + property.items = vec![(key.clone(), value.clone())]; + let descriptor = desc("null").with("null", target.clone()).with("T ", D::Descriptor(property)); + items.push(AtnItem { descriptor: Some(descriptor), ..item.clone() }); + } + Some(items) +} + +/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options +/// `Shift+F5`, `Cmd+F5` or `Cmd+Shift+F5` (#7646). +pub fn function_key_name(key: u16, shift: bool, command: bool) -> String { + format!("{}{}F{key}", if command { "Cmd+" } else { "" }, if shift { "Shift+" } else { "" }) +} + +/// The `.atn` function key (1…15), Shift and Command of a shortcut string, or `None` for +/// anything else. +pub fn parse_function_key(key: &str) -> Option<(u16, bool, bool)> { + let (mut shift, mut command) = (false, false); + let mut rest = key; + loop { + if let Some(r) = rest.strip_prefix("Cmd+").filter(|_| !command) { + (command, rest) = (true, r); + } else if let Some(r) = rest.strip_prefix("Shift+").filter(|_| !shift) { + (shift, rest) = (true, r); + } else { + break; + } + } + let n = rest.strip_prefix('F')?.parse::().ok().filter(|n| (1..=15).contains(n))?; + Some((n, shift, command)) } /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key -/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. pub fn export( set: &str, actions: &[&Action], @@ -3067,24 +3873,31 @@ pub fn export( let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; for a in actions { let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; - if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) - && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) - && (1..=15).contains(&n) - { - out.function_key = n; + if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { + (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { - let Some(mut item) = export_item(id, params, set) else { + let Some(items) = export_items(id, params, set) else { if skip_unsupported { skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); continue; } - return Err(format!("step {} of `{}` (`{id}`) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name)); + let step_name = match params.get("layer") { + Some(Value::Number(n)) => format!("`{id}` with numeric layer {n}"), + _ => format!("`{id}`"), + }; + return Err(format!( + "step {} of `{}` ({step_name}) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", + i + 1, + a.name + )); }; - item.enabled = a.step_enabled(i); - item.with_dialog = a.dialogs.contains(&i); - item.dialog_options = if item.with_dialog { 2 } else { 0 }; - out.items.push(item); + for mut item in items { + item.enabled = a.step_enabled(i); + item.with_dialog = a.dialogs.contains(&i); + item.dialog_options = if item.with_dialog { 2 } else { 0 }; + out.items.push(item); + } } file.actions.push(out); } @@ -3094,12 +3907,34 @@ pub fn export( // ------------------------------------------------------------------ commands -/// A name not used by any action in `taken`: `name`, `name 2`, `name 3`… -fn unique(name: &str, taken: &[String]) -> String { - if !taken.iter().any(|t| t == name) { - return name.to_string(); +/// Assigns names not already used, as `name`, `name 2`, `name 3`… A plain linear scan of the +/// taken list per candidate is cubic when an action file repeats one base name many times, so +/// membership uses a set and the next suffix to try is cached per base name. +struct UniqueNames { + taken: std::collections::HashSet, + next_suffix: std::collections::HashMap, +} + +impl UniqueNames { + fn new>(existing: I) -> Self { + UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } + } + + /// Reserves and returns a unique name derived from `name`, recording it as taken. + fn claim(&mut self, name: &str) -> String { + if self.taken.insert(name.to_string()) { + return name.to_string(); + } + let mut i = self.next_suffix.get(name).copied().unwrap_or(2); + loop { + let candidate = format!("{name} {i}"); + i += 1; + if self.taken.insert(candidate.clone()) { + self.next_suffix.insert(name.to_string(), i); + return candidate; + } + } } - (2..).map(|i| format!("{name} {i}")).find(|n| !taken.contains(n)).unwrap_or_else(|| name.to_string()) } fn load(s: &mut Session, p: &Value) -> Result { @@ -3127,33 +3962,40 @@ fn load(s: &mut Session, p: &Value) -> Result { if replace { s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); } - let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); + let mut taken = UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); let mut names = Vec::with_capacity(imp.actions.len()); - let mut renamed = Vec::new(); + // The file's name of each action that had to be renamed, and its new name. + let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); let mut steps = 0usize; + let mut added = Vec::with_capacity(imp.actions.len()); for mut a in imp.actions { - let name = unique(&a.name, &taken); + let name = taken.claim(&a.name); if name != a.name { - renamed.push((a.name.clone(), name.clone())); + renamed.entry(a.name.clone()).or_insert_with(|| name.clone()); } a.name = name.clone(); - taken.push(name.clone()); steps = steps.saturating_add(a.steps.len()); names.push(name); - s.actions.list.push(a); + added.push(a); } - // A Play step calls its action by name: follow renames within the set. - for (from, to) in &renamed { - for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { - for (id, params) in &mut a.steps { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { - params["action"] = json!(to); + // Play and Conditional steps call actions by name: follow the renames in the actions just + // loaded, each target looked up once by the name the file gave it. + for a in &mut added { + for (id, params) in &mut a.steps { + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if let Some(to) = params.get(*key).and_then(Value::as_str).and_then(|n| renamed.get(n)) { + params[*key] = json!(to); } } } } - let keys: Vec<(String, String)> = - imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + s.actions.list.extend(added); + let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { for (name, key) in &keys { @@ -3176,6 +4018,7 @@ fn load(s: &mut Session, p: &Value) -> Result { fn save(s: &mut Session, p: &Value) -> Result { let cmd = "actions.save"; + crate::actions_cmds::flush_recording(s)?; let skip = match p.get("skipUnsupported") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, @@ -3223,7 +4066,8 @@ fn save(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { - std::fs::write(path, bytes) + // Atomic replacement (temporary file + rename): a failed write keeps the previous ATN (#7290). + photocraft_format::atomic_write(std::path::Path::new(path), bytes) } #[cfg(target_arch = "wasm32")] @@ -3293,3 +4137,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod layer_props_tests; diff --git a/crates/engine/src/atn_cmds/layer_props_tests.rs b/crates/engine/src/atn_cmds/layer_props_tests.rs new file mode 100644 index 0000000000..155729923a --- /dev/null +++ b/crates/engine/src/atn_cmds/layer_props_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +/// PhotoCraft's own roundtrip accepts a combined Set, but Photoshop applies only its rename. +#[test] +fn compound_properties_export_as_separate_sets_with_rename_last() { + for target in [None, Some("Original")] { + let mut params = json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"}); + if let Some(name) = target { + params["layer"] = json!(name); + } + let action = Action::new("Properties", vec![("layer.setProps".into(), params.clone())]); + let (bytes, skipped) = export("Set", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let items = &file.actions[0].items; + assert_eq!(items.len(), 3); + let expected = [("Opct", pct(37.0)), ("Md ", enm("BlnM", "Mltp")), ("Nm ", text("Renamed"))]; + for (item, (key, value)) in items.iter().zip(expected) { + assert_eq!(item.event.id(), "setd"); + let descriptor = item.descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("null"), Some(&layer_ref(¶ms).unwrap())); + let properties = get_obj(descriptor, "T ").unwrap(); + assert!(properties.class_id.is("Lyr ")); + assert_eq!(properties.items.len(), 1, "Photoshop must receive each property independently"); + assert_eq!(properties.get(key), Some(&value)); + } + } +} + +#[test] +fn expanded_properties_keep_flags_and_original_skip_indices() { + let mut action = Action::new( + "Flags", + vec![ + ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), + ("filter.render.clouds".into(), json!({})), + ], + ); + action.disabled.insert(0); + action.dialogs.insert(0); + let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); + assert_eq!(skipped, [Skipped { action: "Flags".into(), step: 2, id: "filter.render.clouds".into() }]); + let file = atn::parse(&bytes).unwrap(); + let items = &file.actions[0].items; + assert_eq!(items.len(), 4); + for item in &items[..3] { + assert!(!item.enabled); + assert!(item.with_dialog); + assert_eq!(item.dialog_options, 2); + } + assert_eq!(items[3].event.id(), "GsnB"); + assert!(items[3].enabled); + assert!(!items[3].with_dialog); + assert_eq!(items[3].dialog_options, 0); + let back = import(&bytes).unwrap(); + assert_eq!(back.actions[0].disabled.iter().copied().collect::>(), [0, 1, 2]); + assert_eq!(back.actions[0].dialogs.iter().copied().collect::>(), [0, 1, 2]); +} + +#[test] +fn invalid_compound_properties_fail_or_skip_the_whole_source_step() { + let action = Action::new( + "Atomic", + vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), + ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "invalid blend"})), + ("select.inverse".into(), json!({})), + ], + ); + let error = export("Set", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2"), "{error}"); + let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); + assert_eq!(skipped, [Skipped { action: "Atomic".into(), step: 1, id: "layer.setProps".into() }]); + let file = atn::parse(&bytes).unwrap(); + let events: Vec<_> = file.actions[0].items.iter().map(|item| item.event.id()).collect(); + assert_eq!(events, ["GsnB", "Invs"]); +} + +#[test] +fn single_layer_properties_keep_their_existing_descriptor() { + for params in [json!({"name": "Renamed"}), json!({"opacity": 0.37}), json!({"blend": "Multiply"}), json!({"fill": 0.5}), json!({"visible": false})] { + let action = Action::new("Single", vec![("layer.setProps".into(), params.clone())]); + let expected = export_item("layer.setProps", ¶ms, "Set").unwrap(); + let (bytes, skipped) = export("Set", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), 1); + assert_eq!(file.actions[0].items[0].event, expected.event); + assert_eq!(file.actions[0].items[0].descriptor, expected.descriptor); + } +} + +#[test] +fn fill_and_visibility_also_export_separately_before_rename() { + let action = Action::new("Other properties", vec![("layer.setProps".into(), json!({"name": "Renamed", "fill": 0.5, "visible": false}))]); + let (bytes, _) = export("Set", &[&action], &[], false).unwrap(); + let file = atn::parse(&bytes).unwrap(); + let keys: Vec<_> = file.actions[0] + .items + .iter() + .map(|item| { + let properties = get_obj(item.descriptor.as_ref().unwrap(), "T ").unwrap(); + assert_eq!(properties.items.len(), 1); + trimmed(&properties.items[0].0) + }) + .collect(); + assert_eq!(keys, ["fillOpacity", "Vsbl", "Nm"]); +} + +#[test] +fn expanded_properties_replay_like_direct_edits_at_every_depth() { + for depth in [8, 16, 32] { + for named in [false, true] { + let prepare = || { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 2, "height": 2, "depth": depth})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Original"})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Other"})).unwrap(); + if !named { + session.execute("layer.select", json!({"layer": "Original"})).unwrap(); + } + session + }; + let mut params = json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"}); + if named { + params["layer"] = json!("Original"); + } + let mut direct = prepare(); + direct.execute("layer.setProps", params.clone()).unwrap(); + let action = Action::new("Properties", vec![("layer.setProps".into(), params)]); + let (bytes, _) = export("Set", &[&action], &[], false).unwrap(); + let mut replay = prepare(); + replay.actions.list = import(&bytes).unwrap().actions; + let result = replay.execute("actions.play", json!({"action": "Properties"})).unwrap(); + assert!(result["failed"].is_null(), "{result}"); + assert_eq!(result["ran"], 3); + let state = |session: &Session| { + session + .active() + .unwrap() + .doc + .walk() + .iter() + .map(|(_, _, layer)| (layer.name.clone(), layer.opacity, layer.blend, layer.visible)) + .collect::>() + }; + assert_eq!(state(&direct), state(&replay), "depth={depth}, named={named}"); + } + } +} diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..d83d69e624 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -6,9 +6,9 @@ use serde_json::json; fn item(event: &str, label: &str, d: Option) -> AtnItem { let event = match event.as_bytes() { [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), - _ => AtnEvent::Name(event.into()), + _ => AtnEvent::name(event), }; - AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() } + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() } } fn b64(bytes: &[u8]) -> String { @@ -66,6 +66,105 @@ fn sample() -> AtnFile { } } +#[test] +fn layer_comp_apply_imports_plays_and_undoes() { + let name = "合成 𠮷"; + let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(desc("null").with("null", name_ref("compsClass", name))))])]); + for depth in [8, 16, 32] { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + assert_eq!(s.actions.list[0].steps, [("layerComp.apply".into(), json!({"comp": name}))]); + + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + s.execute("layerComp.new", json!({"name": "Other"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains(name), "{played}"); + assert_eq!(*s.active().unwrap().doc, *before, "a missing comp must not apply another comp"); + + let comp = s.execute("layerComp.new", json!({"name": name})).unwrap()["comp"].clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.layer(layer).unwrap().visible); + assert_eq!(json!(s.active().unwrap().doc.last_applied_comp), comp); + assert!(s.undo()); + assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible); + assert!(s.redo()); + assert!(s.active().unwrap().doc.layer(layer).unwrap().visible); + } +} + +#[test] +fn layer_comp_apply_round_trips_by_name() { + for name in ["Comp 1", "合成 𠮷"] { + let descriptor = desc("null").with("null", name_ref("compsClass", name)); + let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(descriptor.clone()))])]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items[0].event, AtnEvent::Name("applyComp".into())); + assert_eq!(file.actions[0].items[0].descriptor, Some(descriptor)); + let mut reloaded = Session::new(); + reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(reloaded.actions.list, s.actions.list); + } + // Local comp ids and the default comp cannot be resolved to a portable name at export. + for params in [json!({}), json!({"comp": 1}), json!({"comp": null}), json!({"comp": true}), json!({"comp": []})] { + assert!(export_item("layerComp.apply", ¶ms, "Comps").is_none(), "{params}"); + } +} + +#[test] +fn layer_comp_apply_unknown_references_are_kept_and_stop_playback() { + let references = [ + None, + Some(D::Integer(1)), + Some(D::Reference(vec![])), + Some(name_ref("Lyr ", "Comp 1")), + Some(target_ref("compsClass")), + Some(class_ref("compsClass")), + Some(D::Reference(vec![ReferenceItem::Index { class: class("compsClass"), index: 1 }])), + Some(D::Reference(vec![ReferenceItem::Identifier { class: class("compsClass"), id: 1 }])), + Some(D::Reference(vec![ + ReferenceItem::Name { class: class("compsClass"), name: UnicodeString::new_nul("Comp 1") }, + ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }, + ])), + ]; + let items: Vec<_> = + references.into_iter().map(|reference| item("applyComp", "Apply Layer Comp", reference.map(|r| desc("null").with("null", r)))).collect(); + let data = set_of("Comps", vec![("Unknown", items.clone())]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), items.len()); + assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == PHOTOSHOP_STEP)); + assert!(s.actions.list[0].disabled.is_empty()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layerComp.new", json!({"name": "Comp 1"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let played = s.execute("actions.play", json!({"action": "Unknown"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("Apply Layer Comp"), "{played}"); + assert_eq!(*s.active().unwrap().doc, *before); + let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), items.len()); + for (saved, original) in file.actions[0].items.iter().zip(&items) { + assert_eq!(saved.event, original.event); + assert_eq!(saved.descriptor, original.descriptor); + } +} + #[test] fn load_maps_events_keeps_flags_and_plays() { let mut s = Session::new(); @@ -130,6 +229,51 @@ fn load_maps_events_keeps_flags_and_plays() { assert_eq!(s.actions.list[2].set.as_deref(), Some("Other")); } +/// Renames that chain ("A" becomes "A 2" while the file's own "A 2" becomes "A 2 2") rewrite +/// each Play and Conditional target once, to the renamed copy of the action it named. +#[test] +fn load_renames_each_play_target_once() { + let mut s = Session::new(); + let mut mine = Action::new("A", vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new("A", vec![("actions.conditional".into(), json!({"if": "always", "then": "A", "else": "A 2"}))]); + let a2 = Action::new("A 2", vec![("actions.play".into(), json!({"action": "A"}))]); + let keys = [("A".to_string(), "F4".to_string())]; + let (bytes, _) = export("File", &[&a, &a2], &keys, false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["actions"], json!(["A 2", "A 2 2"])); + let get = |n: &str| s.actions.list.iter().find(|a| a.name == n && a.set.as_deref() == Some("File")).unwrap(); + assert_eq!(get("A 2 2").steps[0].1["action"], "A 2", "plays the file's A, not its A 2"); + let cond = &get("A 2").steps[0].1; + assert_eq!((&cond["then"], &cond["else"]), (&json!("A 2"), &json!("A 2 2")), "{cond}"); + assert_eq!(r["functionKeys"], json!([{"action": "A 2", "key": "F4"}])); + + // Adding to a set of the same name leaves the Play steps already in it alone. + let mut s = Session::new(); + for (name, steps) in [("A", vec![]), ("B", vec![("actions.play".to_string(), json!({"action": "A"}))])] { + let mut a = Action::new(name, steps); + a.set = Some("File".into()); + s.actions.list.push(a); + } + let (bytes, _) = export("File", &[&Action::new("A", vec![])], &[], false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes), "replace": false})).unwrap(); + assert_eq!(r["actions"], json!(["A 2"])); + assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); +} + +#[test] +fn load_many_duplicate_names_get_sequential_suffixes() { + // Several actions share one base name. Deduplication must stay O(n) overall and produce + // the sequence A, A 2, A 3, A 4 (the next-suffix cache cannot skip or repeat a number). + let mut s = Session::new(); + let dupes: Vec = (0..4).map(|_| Action::new("A", vec![])).collect(); + let refs: Vec<&Action> = dupes.iter().collect(); + let (bytes, _) = export("File", &refs, &[], false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["actions"], json!(["A", "A 2", "A 3", "A 4"])); +} + #[test] fn save_round_trips_through_photoshop_events() { let mut s = Session::new(); @@ -144,7 +288,7 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(file.name.to_string_lossy(), "Test Set"); assert_eq!(file.actions[0].function_key, 3); let events: Vec = file.actions[0].items.iter().map(|i| i.event.id()).collect(); - assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]); assert!(file.actions[0].items[0].with_dialog); assert!(!file.actions[0].items[8].enabled); // The kept Photoshop step is written back byte for byte. @@ -166,6 +310,97 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(r["set"], "Ours"); } +/// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as +/// the scalar form that names one range (`"colors": "reds", "cyan": 35`). Only the arrays were +/// written, so a step recorded the other way exported as an adjustment that changes nothing, with +/// `skipped` empty (#7672). +#[test] +fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { + let back = |params: Value| -> Value { + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), params)])); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + imported.actions[0].steps[0].1.clone() + }; + + // The numbers are read back as numbers; a percentage may arrive as 35 or 35.0. + let range = + |step: &Value, name: &str| -> Vec { step[name].as_array().unwrap_or(&Vec::new()).iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect() }; + + // The reported step: one range, one channel. + let scalar = back(json!({"method": "absolute", "colors": "reds", "cyan": 35})); + assert_eq!(scalar["method"], "absolute"); + assert_eq!(range(&scalar, "reds"), [35.0, 0.0, 0.0, 0.0], "{scalar}"); + + // The scalar form over an array keeps both, as playing the step would. + let both = back(json!({"reds": [10, 20, 30, 40], "colors": "reds", "cyan": 35})); + assert_eq!(range(&both, "reds"), [35.0, 20.0, 30.0, 40.0], "{both}"); + + // The array form is unchanged. + let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]})); + assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}"); + + // A channel the command would refuse is not quietly dropped: the step is refused too, since + // writing it would export an adjustment that is not the one recorded. `null` is absent, as + // the command reads it. + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))])); + let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); + assert!(e.contains("image.adjustments.selectiveColor"), "{e}"); + + let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null})); + assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); +} + +/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back +/// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and +/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. +#[test] +fn a_setting_the_event_cannot_hold_is_refused() { + for (id, params) in [ + ("filter.pixelate.crystallize", json!({"cellSize": 5, "seed": 37})), + ("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})), + ("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})), + ("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})), + ] { + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(id.into(), params)])); + let before = s.actions.list[0].steps.clone(); + let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); + assert!(e.contains(id), "{id}: {e}"); + // A refused save leaves the action as it was: the step is still there to be played. + assert_eq!(s.actions.list[0].steps, before, "{id}"); + let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); + assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}"); + assert_eq!(s.actions.list[0].steps, before, "{id}: skipping a step does not drop it"); + } +} + +/// The same steps at the value the event plays as, and with the setting absent: written as before. +#[test] +fn settings_at_the_value_the_event_plays_still_export() { + for (id, params) in [ + ("filter.pixelate.crystallize", json!({"cellSize": 5, "seed": 0})), + ("filter.pixelate.crystallize", json!({"cellSize": 5})), + ("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.5, "centerY": 0.5})), + ("filter.blur.radialBlur", json!({"amount": 10})), + ("filter.other.minimum", json!({"radius": 4, "preserve": "squareness"})), + ("filter.other.maximum", json!({"radius": 4})), + // `preserve` reaches the filter through a comparison with the exact string "roundness", + // so these play as squareness -- replay's own value -- and lose nothing by export (#7759 + // review). + ("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})), + ("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})), + ] { + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + assert_eq!(r["skipped"], json!([]), "{id} {params}"); + } +} + #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ @@ -173,7 +408,9 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), ("filter.noise.median", json!({"radius": 2})), + ("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), ( @@ -186,6 +423,10 @@ fn every_mapped_command_exports_and_imports_back() { json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}), ), ("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})), + ( + "image.adjustments.shadowsHighlights", + json!({"shadowAmount": 60, "shadowTone": 40, "shadowRadius": 25, "highlightAmount": 10, "highlightTone": 50, "highlightRadius": 30, "blackClip": 0.02, "whiteClip": 0.05, "midtone": 15, "color": 20}), + ), ("image.adjustments.threshold", json!({"level": 100})), ("image.adjustments.posterize", json!({"levels": 4})), ("image.adjustments.invert", json!({})), @@ -198,7 +439,20 @@ fn every_mapped_command_exports_and_imports_back() { json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}), ), ("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})), - ("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "neutrals": [0, 0, 0, 3]})), + ("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "yellows": [0, 5, 0, 0], "neutrals": [0, 0, 0, 3]})), + ("select.subject", json!({"sampleAllLayers": false})), + ("select.colorRange", json!({"select": "midtones", "fuzziness": 30, "tonalRange": [90, 160], "invert": true})), + ("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})), + ("select.colorRange", json!({"select": "highlights", "tonalRange": 200})), + ("select.colorRange", json!({"select": "reds"})), + ("layer.createClippingMask", json!({})), + ("layer.releaseClippingMask", json!({"layer": "Shadow"})), + ("layer.layerMask.revealAll", json!({})), + ("layer.layerMask.hideAll", json!({})), + ("layer.layerMask.revealSelection", json!({})), + ("layer.layerMask.hideSelection", json!({})), + ("tools.setColors", json!({"foreground": "#336699"})), + ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), @@ -237,6 +491,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setProps", json!({"layer": "Shadow", "visible": false})), ("layer.setLabelColor", json!({"color": "red"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), + ("layer.select", json!({"layer": "Shadow", "mode": "range"})), ("layer.selectTop", json!({})), ("layer.selectBelow", json!({})), ("layer.addBelowToSelection", json!({})), @@ -299,8 +554,19 @@ fn every_mapped_command_exports_and_imports_back() { ("file.openAs", json!({"path": "/tmp/a.psd"})), ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), + ("tools.setColors", json!({"foreground": "#102030"})), + ("tools.setColors", json!({"background": "#ffffff"})), + ("view.fitOnScreen", json!({})), + ("view.zoomIn", json!({})), + ("layer.createClippingMask", json!({})), + ("layer.ungroupLayers", json!({})), + ("layer.layerStyle.copyLayerStyle", json!({})), + ("layer.layerStyle.pasteLayerStyle", json!({})), + ("layer.setExpanded", json!({"all": true, "expanded": false})), + ("layerComp.apply", json!({"comp": "Original"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), + ("document.activate", json!({"offset": -1})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), ("filter.pixelate.crystallize", json!({"cellSize": 5})), @@ -321,6 +587,7 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.bitmap", json!({"method": "threshold"})), ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), + ("layerComp.apply", json!({"comp": "Comp 1"})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -399,6 +666,8 @@ fn every_mapped_command_exports_and_imports_back() { "select.modify.smooth", "select.reselect", "select.similar", + "view.actualPixels", + "view.zoomOut", "tools.swapColors", "edit.cut", "edit.copy", @@ -412,7 +681,6 @@ fn every_mapped_command_exports_and_imports_back() { "filter.blur.blurMore", "filter.blur.boxBlur", "filter.blur.radialBlur", - "filter.blur.smartBlur", "filter.blur.surfaceBlur", "filter.noise.despeckle", "filter.noise.dustAndScratches", @@ -428,7 +696,146 @@ fn every_mapped_command_exports_and_imports_back() { // Every command the table names exists. // (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.) for id in EVENTS.iter().flat_map(|e| e.ids.iter()) { - assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`"); + assert!( + crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id), + "the .atn table names an unknown command `{id}`" + ); + } +} + +/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export. +fn bucket_round_trip(params: Value) -> Option { + let item = export_item("paint.bucket", ¶ms, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(id, p)| { + assert_eq!(id, "paint.bucket"); + p.clone() + }), + _ => None, + } +} + +/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh +/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the +/// step not export rather than fill with the foreground. +#[test] +fn bucket_keeps_an_explicit_color_through_atn() { + let fill = |params: &Value| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap(); + s.execute("paint.bucket", params.clone()).unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2) + }; + let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"}); + let back = bucket_round_trip(red.clone()).unwrap(); + assert_eq!(back["color"], "#ff0000", "{back}"); + assert_eq!(fill(&back), fill(&red)); + assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]); + // The colour as an array of channels. + assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff"); + // No colour, or one the command can't read: the foreground, as before. + for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] { + let back = bucket_round_trip(p.clone()).unwrap(); + assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}"); + } + // A pattern fill ignores the colour, also when named with the older `source`. + for key in ["contents", "source"] { + let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap(); + assert_eq!(back["contents"], "pattern", "{key}: {back}"); + assert!(back.get("color").is_none(), "{key}: {back}"); + } + // Translucent or out-of-range colours have no .atn form. + for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] { + assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}"); + } +} + +/// `image.adjustments.photoFilter` exported to .atn and imported back → its params, or None. +fn photo_filter_round_trip(params: Value) -> Option { + let item = export_item("image.adjustments.photoFilter", ¶ms, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(id, p)| { + assert_eq!(id, "image.adjustments.photoFilter"); + p.clone() + }), + _ => None, + } +} + +/// A `Mk` layerSection step exported to .atn and imported back → (id, params), or None. +fn group_round_trip(id: &str, params: &Value) -> Option<(String, Value)> { + let item = export_item(id, params, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(i, p)| (i.clone(), p.clone())), + _ => None, + } +} + +#[test] +fn photo_filter_preset_only_step_keeps_its_resolved_color() { + // #7235: the Photoshop descriptor keeps only a colour (`phfl`/`PhtF` are colour-only; the io + // notes reach the same conclusion from the CS2 oracles). A step recorded with only `filter` + // previously exported with no colour at all, so re-imported playback fell back to the default + // warming filter; it must export the resolved colour of the chosen preset instead. + let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "density": 60, "preserveLuminosity": true})).unwrap(); + normalise(&mut back); + assert_eq!(back, json!({"color": "#006dff", "density": 60, "preserveLuminosity": true})); + // An omitted density stays omitted: running the imported step applies the same default. + let mut back = photo_filter_round_trip(json!({"filter": "red"})).unwrap(); + normalise(&mut back); + assert_eq!(back, json!({"color": "#ea1a1a"})); + // With an explicit colour too, the colour wins, as when running the command. + let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "color": "#123456", "density": 25})).unwrap(); + normalise(&mut back); + assert_eq!(back, json!({"color": "#123456", "density": 25})); + let mut back = photo_filter_round_trip(json!({"color": "#123456", "density": 25})).unwrap(); + normalise(&mut back); + assert_eq!(back, json!({"color": "#123456", "density": 25})); + // An unknown preset id keeps the old export: no colour resolves, playback applies its default. + let mut back = photo_filter_round_trip(json!({"filter": "nope", "density": 25})).unwrap(); + normalise(&mut back); + assert_eq!(back, json!({"density": 25})); +} + +#[test] +fn group_layers_keeps_its_explicit_target_through_atn() { + // #7236: a recorded step with an explicit layer groups that layer, not the current + // selection. The source goes into the `From` reference by name and comes back the same + // way; a step with no source layer keeps the implicit form, imported as before. + let (id, p) = group_round_trip("layer.groupLayers", &json!({"layer": "A", "name": "Group"})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({"layer": "A", "name": "Group"})); + // The implicit form survives unchanged: the reference stays the target layer. + let (id, p) = group_round_trip("layer.groupLayers", &json!({"name": "Group"})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({"name": "Group"})); + let (id, p) = group_round_trip("layer.groupLayers", &json!({})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({})); + // Group Without Layers: no `From` at all, imported as a plain new group. + let (id, p) = group_round_trip("layer.new.group", &json!({"name": "G"})).unwrap(); + assert_eq!(id, "layer.new.group"); + assert_eq!(p, json!({"name": "G"})); +} + +#[test] +fn group_layers_from_index_reference_stays_implicit() { + // A Photoshop-authored `From` that names nothing portable (a zero-based index) keeps the + // old implicit import instead of inventing a wrong explicit target (#7236, edge). + let d = desc("null") + .with("null", class_ref("layerSection")) + .with("From", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 2 }])) + .with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text("Group")))); + match make_from(&d) { + Mapped::Steps(steps) => { + assert_eq!(steps[0].0, "layer.groupLayers"); + assert_eq!(steps[0].1, json!({"name": "Group"})); + } + _ => panic!("an index source imports as the implicit form"), } } @@ -479,6 +886,141 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } +/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening, +/// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them. +#[test] +fn retouching_set_events_map_to_their_commands() { + let smart_brush = desc("null") + .with("smartBrushRadius", px(3.0)) + .with("smartBrushSmooth", long(2.0)) + .with("smartBrushFeather", px(0.5)) + .with("smartBrushContrast", pct(10.0)) + .with("smartBrushShiftEdge", pct(-20.0)) + .with("sampleAllLayers", D::Boolean(false)) + .with("smartBrushUseSmartRadius", D::Boolean(true)) + .with("smartBrushDecontaminate", D::Boolean(false)) + .with("smartBrushDeconAmount", pct(100.0)) + .with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask")); + let selective = desc("null").with("Mthd", enm("CrcM", "Rltv")).with( + "ClrC", + D::List(vec![ + D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", "Ylws")).with("Cyn ", pct(-30.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(-40.0)).with("Blck", pct(0.0)), + ), + D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", "Whts")).with("Cyn ", pct(0.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(0.0)).with("Blck", pct(-10.0)), + ), + ]), + ); + let lab = |l: f64| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(l)).with("A ", D::Double(0.0)).with("B ", D::Double(0.0))); + let sampled = desc("null").with("Fzns", long(40.0)).with("Mnm ", lab(80.0)).with("Mxm ", lab(95.0)).with("colorModel", long(0.0)); + let mask = desc("null") + .with("Nw ", D::Class(class("Chnl"))) + .with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }])) + .with("Usng", enm("UsrM", "RvlS")); + let fg = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }])) + .with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0)))); + let lab_fg = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("BckC") }])).with("T ", lab(50.0)); + let items = vec![ + item("autoCutout", "Select Subject", Some(desc("null").with("sampleAllLayers", D::Boolean(false)))), + item("smartBrushWorkspace", "Select and Mask", Some(smart_brush)), + item("selectiveColor", "Selective Color", Some(selective)), + item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))), + item("colorRange", "Color Range", Some(sampled)), + item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))), + item("make", "Make", Some(mask)), + item("set", "Set", Some(fg)), + item("set", "Set", Some(lab_fg)), + item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))), + item("GrpL", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))), + item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", name_ref("Lyr ", "Shadow")))), + item("someFutureEvent", "Something New", Some(desc("null").with("Amnt", long(5.0)))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Retouch"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let expected = [ + ("select.subject", json!({"sampleAllLayers": false})), + ( + "select.refineEdge", + json!({"radius": 3, "smartRadius": true, "smooth": 2, "feather": 0.5, "contrast": 10, "shiftEdge": -20, "decontaminate": false, "amount": 100, "output": "layerMask", "sampleAllLayers": false}), + ), + ("image.adjustments.selectiveColor", json!({"method": "relative", "yellows": [-30, 0, -40, 0], "whites": [0, 0, 0, -10]})), + ("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})), + (PHOTOSHOP_STEP, Value::Null), + (PHOTOSHOP_STEP, Value::Null), + ("layer.layerMask.revealSelection", json!({})), + ("tools.setColors", json!({"foreground": "#0000ff"})), + (PHOTOSHOP_STEP, Value::Null), + ("layer.createClippingMask", json!({})), + ("layer.createClippingMask", json!({})), + ("layer.createClippingMask", json!({"layer": "Shadow"})), + (PHOTOSHOP_STEP, Value::Null), + ]; + assert_eq!(steps.len(), expected.len(), "{steps:?}"); + for ((id, params), (want_id, want)) in steps.iter().zip(&expected) { + assert_eq!(id, want_id, "{steps:?}"); + if *want_id != PHOTOSHOP_STEP { + assert_eq!(params, want, "{id}"); + } + } + // Sampled Colors (Lab range), an unknown Color Range key, a Lab colour and an unknown + // event stay unsupported rather than play with other values. + let unsupported: Vec<(&str, usize)> = imp.unsupported.iter().map(|u| (u.event.as_str(), u.step)).collect(); + assert_eq!(unsupported, [("colorRange", 4), ("colorRange", 5), ("set", 8), ("someFutureEvent", 12)]); + // A legacy PhotoCraft export's `Yllw` yellows still read. + let legacy = desc("null").with("ClrC", D::List(vec![D::Descriptor(desc("ClrC").with("Clrs", enm("Clrs", "Yllw")).with("Cyn ", pct(5.0)))])); + assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]}))); + // The mask of a named layer is not the target layer's mask: it stays unsupported. + let other_layer = desc("null") + .with("Nw ", D::Class(class("Chnl"))) + .with( + "At ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Other") }, + ]), + ) + .with("Usng", enm("UsrM", "RvlA")); + assert!(matches!(make_from(&other_layer), Mapped::Unsupported { harmless: false })); +} + +/// Select and Mask exports as the older `refineSelectionEdge` event, which has no Sample All +/// Layers; a step that sets it exports as `smartBrushWorkspace` so the value survives. +#[test] +fn select_and_mask_keeps_sample_all_layers_on_export() { + let (event, d) = event_and_descriptor("select.refineEdge", &json!({"radius": 3, "output": "layerMask"})).unwrap(); + assert_eq!(event.name, "refineSelectionEdge"); + assert!(d.get("refineEdgeBorderRadius").is_some()); + for sample in [true, false] { + let params = json!({"radius": 3, "output": "layerMask", "sampleAllLayers": sample}); + let (event, d) = event_and_descriptor("select.refineEdge", ¶ms).unwrap(); + assert_eq!(event.name, "smartBrushWorkspace"); + let Mapped::Steps(steps) = (event.from)(&d) else { panic!("{d:?}") }; + assert_eq!(steps[0].1["sampleAllLayers"], sample); + } +} + +#[test] +fn crop_sides_in_other_units_are_refused_not_played_as_pixels() { + let rect = |unit: [u8; 4]| { + let side = |v: f64| D::UnitFloat { unit, value: v }; + desc("null") + .with("To ", D::Descriptor(desc("Rctn").with("Top ", side(10.0)).with("Left", side(10.0)).with("Btom", side(90.0)).with("Rght", side(90.0)))) + }; + for unit in [PCT, *b"#Rlt"] { + assert!(matches!(crop_from(&rect(unit)), Mapped::Unsupported { harmless: false }), "{}", String::from_utf8_lossy(&unit)); + } + let Mapped::Steps(steps) = crop_from(&rect(PX)) else { panic!("a crop in pixels maps") }; + assert_eq!(steps, [("image.crop".to_string(), json!({"x": 10.0, "y": 10.0, "width": 80.0, "height": 80.0}))]); +} + #[test] fn step_commands_and_bad_params_fail_gracefully() { let mut s = Session::new(); @@ -536,6 +1078,33 @@ fn save_writes_the_file_and_the_set_of_the_selected_action() { std::fs::remove_dir_all(&dir).ok(); } +/// #7290: saving over an existing ATN must replace it atomically (a temporary file renamed over +/// it), never truncate and rewrite it in place, so a write that fails partway (a full disk) +/// leaves the previously valid file readable. A handle opened on the old file still sees the old +/// bytes after an atomic replacement; an in-place rewrite changes them under it. +#[cfg(unix)] +#[test] +fn save_over_an_existing_file_never_rewrites_it_in_place() { + use std::io::Read; + let mut s = Session::new(); + s.actions.list.push(Action::new("Owned", vec![("select.all".into(), json!({}))])); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Owned.atn"); + let p = path.to_string_lossy().to_string(); + s.execute("actions.save", json!({"path": p})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut old = std::fs::File::open(&path).unwrap(); + s.actions.list.push(Action::new("Second", vec![("select.all".into(), json!({}))])); + s.execute("actions.save", json!({"path": p})).unwrap(); + let mut seen = Vec::new(); + old.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, before, "the existing ATN was rewritten in place"); + let after = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after.actions.len(), 2); + let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, vec![std::ffi::OsString::from("Owned.atn")], "no temporary file is left behind"); +} + #[test] fn layer_param_accepts_a_name() { let mut s = Session::new(); @@ -573,7 +1142,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { let moved = desc("null") .with("null", target_ref("Lyr ")) .with("FTcs", enm("QCSt", "Qcsa")) - .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.0)).with("Vrtc", px(12.0)))) .with("Intr", enm("Intp", "Bcbc")); let scaled = desc("null") .with("null", target_ref("Lyr ")) @@ -758,6 +1327,50 @@ fn remaining_events_import_in_their_photoshop_shapes() { let rest_steps = &rest.actions[0].steps; let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + // A transform without a reference (a replaced smart object) is the target layer's; the + // selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the + // foreground colour. + let replaced = desc("null") + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("replaceLayer", D::Descriptor(desc("null"))); + let nudged = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0)))); + let to_bottom = desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }])) + .with("Adjs", D::Boolean(false)); + let fit = + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }])); + let fg = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }])) + .with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)))); + let items = vec![ + item("transform", "Transform", Some(replaced)), + item("move", "Move", Some(nudged)), + item("move", "Move", Some(to_bottom)), + item("select", "Select", Some(fit)), + item("set", "Set", Some(fg)), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More2"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let more = import(&atn::write(&file).unwrap()).unwrap(); + let more_steps = &more.actions[0].steps; + assert_eq!( + more_steps.iter().map(|(id, _)| id.as_str()).collect::>(), + ["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"] + ); + assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50})); + assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2})); + assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); + assert!(more.unsupported.is_empty()); assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); @@ -789,6 +1402,74 @@ fn fsel(t: D) -> Descriptor { desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t) } +/// #7103: Photoshop records switching to another open document as a `select` of `Dcmn` by +/// offset (previous -1, next +1). The template action of the report, which copies from the +/// source image and pastes into the template opened after it, imports with no Photoshop steps, +/// plays across the two documents and saves back the same shape. With no previous document +/// open, playback stops at the switch instead of pasting into the wrong document. +#[test] +fn select_document_by_offset_imports_plays_and_saves_back() { + let doc = |offset: i32| desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])); + // A recorded document id (`DocI`) means nothing in another session: ignored, not written back. + let copy_in = vec![ + item("slct", "Select", Some(doc(-1).with("DocI", long(35.0)))), + item("setd", "Set", Some(fsel(enm("Ordn", "Al ")))), + item("copy", "Copy", None), + item("slct", "Select", Some(doc(1))), + item("past", "Paste", None), + ]; + let data = set_of("Template", vec![("Copy In", copy_in)]); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "source"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "template", "background": "transparent"})).unwrap(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let steps = &s.actions.list[0].steps; + assert_eq!(steps[0], ("document.activate".to_string(), json!({"offset": -1}))); + assert_eq!(steps[3], ("document.activate".to_string(), json!({"offset": 1}))); + + let pixel = |s: &mut Session| -> Vec { serde_json::from_value(s.execute("document.pixel", json!({"x": 4, "y": 4})).unwrap()).unwrap() }; + assert_eq!(pixel(&mut s), [0.0; 4]); + let r = s.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().doc.name, "template"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "the source's pixels are pasted on a new layer"); + assert_eq!(pixel(&mut s), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(s.documents()[0].doc.layers.len(), 1, "the source is only copied from"); + + let first = s.actions.list.clone(); + let r = s.execute("actions.save", json!({"set": "Template"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + let offsets: Vec = file.actions[0] + .items + .iter() + .filter_map(|i| match get_ref(i.descriptor.as_ref()?, "null")? { + [ReferenceItem::Offset { class, offset }] if trimmed(&class.class_id) == "Dcmn" => Some(*offset), + _ => None, + }) + .collect(); + assert_eq!(offsets, [-1, 1]); + assert!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("DocI").is_none()); + let mut t = Session::new(); + t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(t.actions.list, first); + // An absolute tab index has no Photoshop equivalent: it stays out of a strict save. + assert!(export_item("document.activate", &json!({"document": 0}), "S").is_none()); + assert!(export_item("document.activate", &json!({"document": 0, "offset": 1}), "S").is_none()); + + let mut one = Session::new(); + one.execute("file.new", json!({"width": 8, "height": 8, "name": "template"})).unwrap(); + one.execute("actions.load", json!({"data": data})).unwrap(); + let r = one.execute("actions.play", json!({"action": "Copy In"})).unwrap(); + assert_eq!(r["failed"]["step"], 0, "{r}"); + assert_eq!(r["failed"]["error"], "no document 1 before the active one, which is 1 of 1", "{r}"); + assert_eq!(one.active().unwrap().doc.layers.len(), 1); +} + fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { let file = AtnFile { name: UnicodeString::new_nul(name), @@ -956,3 +1637,1051 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } + +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn`, `BlnW` +/// and `PhtF` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite`, `photoFilter` and the code `FltI` run. +#[test] +fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { + let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let photo_filter = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); + let cases = [ + ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), + ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), + ("layer.flattenImage", json!({}), "FltI", "Fltn"), + ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ("image.adjustments.photoFilter", photo_filter, "photoFilter", "PhtF"), + ]; + for (id, params, written, legacy) in &cases { + let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs"); + assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name"); + let d = exported.descriptor.clone(); + // The written id, the name for Flatten Image, and the old code all import to the command. + let name = if *id == "layer.flattenImage" { "flattenImage" } else { written }; + for event in [*written, name, *legacy] { + let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import")); + assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids); + match (e.from)(d.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{event}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{event}"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + assert_eq!(back, *params, "{event}"); + } + Mapped::Unsupported { .. } => panic!("{event} does not import back"), + } + } + } + + // A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones + // (legacy codes) loads to the same commands. + let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0)))); + let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0)))); + let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)]; + let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)]; + let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]); + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]); + assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]); + assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15})); + // Saving them writes the ids Photoshop runs, never the old codes. + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]); + + // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, + // never a panic or a wrong command. + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx", "PHTF", "PhtF ", "photofilter"] { + let e = event_for(&item(event, "", None).event); + assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); + } + // A legacy code with a broken descriptor is unsupported, not a crash. + let e = event_for(&item("SrfB", "", None).event).unwrap(); + assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_))); + // A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults. + match (e.from)(&desc("null").with("Rds ", text("x"))) { + Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"), + Mapped::Unsupported { .. } => {} + } +} + +/// #8301: Photoshop stops on a Photo Filter step saved as `PhtF` ("not currently available") +/// and runs the same descriptor under the name `photoFilter`. A recorded step saves by name, +/// a Photoshop-authored step re-saves by name, and actions saved before with `PhtF` still load +/// and play the same. +#[test] +fn photo_filter_saves_under_the_name_photoshop_runs() { + let params = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080"})).unwrap(); + s.execute("actions.record", json!({"name": "Filter"})).unwrap(); + s.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved = s.execute("actions.save", json!({"action": "Filter"})).unwrap(); + assert_eq!(saved["skipped"], json!([]), "{saved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let step = &file.actions[0].items[0]; + assert_eq!(step.event, AtnEvent::Name("photoFilter".into()), "never the code PhtF"); + assert_eq!(step.dictionary, "Photo Filter".as_bytes()); + let d = step.descriptor.clone().unwrap(); + for key in ["Clr ", "Dnst", "PrsL"] { + assert!(d.get(key).is_some(), "the descriptor keeps {key}: {d:?}"); + } + + // A Photoshop-authored step (by name) and an old PhotoCraft one (`PhtF`) both load to the + // command, and saving them again writes the name. + let data = + set_of("S", vec![("Photoshop", vec![item("photoFilter", "Photo Filter", Some(d.clone()))]), ("Legacy", vec![item("PhtF", "Photo Filter", Some(d))])]); + let mut t = Session::new(); + let r = t.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + for a in &t.actions.list { + assert_eq!(a.steps.len(), 1, "{}", a.name); + assert_eq!(a.steps[0].0, "image.adjustments.photoFilter", "{}", a.name); + let mut p = a.steps[0].1.clone(); + normalise(&mut p); + assert_eq!(p, params, "{}", a.name); + } + let resaved = t.execute("actions.save", json!({"set": "S"})).unwrap(); + assert_eq!(resaved["skipped"], json!([]), "{resaved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(resaved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["photoFilter", "photoFilter"]); + + // Both play like the command run directly, at 8 and 16 bits. + for depth in [8, 16] { + let pixel = |s: &Session| { + let st = s.active().unwrap(); + let surface = st.doc.layers.first().unwrap().surface().unwrap(); + [0, 1, 2].map(|c| surface.sample_channel(3, 3, c)) + }; + let new_doc = |t: &mut Session| { + t.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080", "depth": depth})).unwrap(); + }; + new_doc(&mut t); + t.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + let direct = pixel(&t); + assert_ne!(direct, [128.0 / 255.0; 3], "the filter changes the grey"); + for action in ["Photoshop", "Legacy"] { + new_doc(&mut t); + let r = t.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{action}: {r}"); + assert_eq!(pixel(&t), direct, "{action} at {depth} bits"); + } + } +} + +/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in +/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. +#[test] +fn smart_blur_quality_uses_photoshop_codes() { + let smart_blur = + |quality: D, mode: &str| desc("null").with("Rds ", D::Double(6.0)).with("Thsh", D::Double(60.0)).with("SmBQ", quality).with("SmBM", enm("SmBM", mode)); + // Every quality writes Photoshop's code, as a double radius/threshold and both enums, + // and reads back as the same step. + for (quality, code) in [("low", "SBQL"), ("medium", "SBQM"), ("high", "SBQH")] { + let params = json!({"radius": 6, "threshold": 60, "quality": quality, "mode": "edgeOnly"}); + let a = Action::new("Q", vec![("filter.blur.smartBlur".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let it = &file.actions[0].items[0]; + assert_eq!(it.event.id(), "SmrB"); + assert_eq!(it.descriptor.as_ref(), Some(&smart_blur(enm("SmBQ", code), "SBME")), "{quality}"); + let back = import(&bytes).unwrap(); + assert!(back.unsupported.is_empty(), "{quality}"); + assert_eq!(back.actions[0].steps, vec![("filter.blur.smartBlur".to_string(), params)]); + } + + // Photoshop-shaped steps (each quality and mode, as Photoshop stores them) and the codes + // PhotoCraft wrote before this fix read as the right quality. + let cases = [ + ("SBQH", "SBMN", "high", "normal"), + ("SBQM", "SBMO", "medium", "overlayEdge"), + ("SBQL", "SBME", "low", "edgeOnly"), + ("Hgh ", "SBMN", "high", "normal"), + ("Mdm ", "SBMN", "medium", "normal"), + ("Low ", "SBMN", "low", "normal"), + ]; + let items = cases.iter().map(|(q, m, _, _)| item("SmrB", "Smart Blur", Some(smart_blur(enm("SmBQ", q), m)))).collect(); + let data = set_of("Blurs", vec![("Smart", items)]); + let mut s = Session::new(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let steps = &s.actions.list[0].steps; + for ((q, _, quality, mode), (id, p)) in cases.iter().zip(steps) { + assert_eq!(id, "filter.blur.smartBlur", "{q}"); + assert_eq!(p, &json!({"radius": 6, "threshold": 60, "quality": quality, "mode": mode}), "{q}"); + } + + // An unknown code, a quality of the wrong type, NaN and a missing quality never panic: the + // unknown ones stay Photoshop steps, kept verbatim. + let odd = [ + smart_blur(enm("SmBQ", "Xxxx"), "SBMN"), + smart_blur(D::Integer(2), "SBMN"), + smart_blur(enm("SmBQ", "SBQH"), "Xxxx"), + desc("null").with("Rds ", D::Double(f64::NAN)).with("SmBQ", enm("SmBQ", "SBQM")), + desc("null"), + ]; + let items: Vec = odd.iter().map(|d| item("SmrB", "Smart Blur", Some(d.clone()))).collect(); + let imported = import(&photocraft_paint::tile::b64_decode(&set_of("Odd", vec![("Odd", items)])).unwrap()).unwrap(); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), odd.len()); + assert_eq!(steps[0].0, PHOTOSHOP_STEP, "an unknown quality is kept verbatim"); + assert_eq!(steps[2].0, PHOTOSHOP_STEP, "an unknown mode is kept verbatim"); + assert_eq!(imported.unsupported.iter().map(|u| u.step).collect::>(), [0, 2]); + // A wrong-type quality is left out (the filter's default applies), as for every enum field. + assert_eq!(steps[1], ("filter.blur.smartBlur".to_string(), json!({"radius": 6, "threshold": 60, "mode": "normal"}))); + // An unknown quality or a non-string one is not exported. + for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] { + let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]); + assert!(export("S", &[&a], &[], false).is_err(), "{bad}"); + } +} + +/// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as +/// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. +#[test] +fn photoshop_shadows_highlights_steps_play() { + let tones = |amount: f64, width: f64, radius: i32| { + D::Descriptor(desc("adaptCorrectTones").with("amount", pct(amount)).with("width", pct(width)).with("radius", D::Integer(radius))) + }; + let d = desc("null") + .with("shadowMode", tones(35.0, 50.0, 30)) + .with("highlightMode", tones(20.0, 40.0, 24)) + .with("blackClip", D::Double(0.01)) + .with("whiteClip", D::Double(0.02)) + .with("center", D::Integer(5)) + .with("colorCorrection", D::Integer(20)); + let e = event_for(&item("adaptCorrect", "Sombra/Realce", None).event).expect("adaptCorrect is mapped"); + let Mapped::Steps(steps) = (e.from)(&d) else { panic!("Shadows/Highlights maps to a step") }; + assert_eq!(steps.len(), 1); + let (id, p) = &steps[0]; + assert_eq!(id, "image.adjustments.shadowsHighlights"); + let mut p = p.clone(); + normalise(&mut p); + assert_eq!( + p, + json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "highlightAmount": 20, "highlightTone": 40, "highlightRadius": 24, "blackClip": 0.01, "whiteClip": 0.02, "midtone": 5, "color": 20}) + ); + // The same settings as char IDs read the same; absent keys are left to the defaults. + let codes = desc("null") + .with("sdwM", D::Descriptor(desc("adaptCorrectTones").with("Amnt", pct(35.0)).with("Wdth", pct(50.0)).with("Rds ", D::Integer(30)))) + .with("BlcC", D::Double(0.01)); + let Mapped::Steps(steps) = (e.from)(&codes) else { panic!("char IDs map too") }; + let mut q = steps[0].1.clone(); + normalise(&mut q); + assert_eq!(q, json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "blackClip": 0.01})); + // And it runs on a document. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#404040"})).unwrap(); + s.execute(id, p).unwrap(); +} + +#[test] +fn save_while_recording_includes_pending_steps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Pending"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Recorded"})).unwrap(); + + // While still recording (without calling actions.stop), save the action. + let saved = s.execute("actions.save", json!({"action": "Pending"})).unwrap(); + assert_eq!(saved["steps"], 1, "pending steps must be flushed and included in save"); + assert_eq!(saved["skipped"], json!([])); + + // After save, recording continues and additional steps are flushed on stop without duplication. + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + + let action = s.execute("actions.get", json!({"action": "Pending"})).unwrap(); + assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication"); +} + +/// #7233: the Minimum / Maximum descriptor carries the radius only, so a step with Preserve: +/// Roundness is refused by strict export (and left out, reported, otherwise) instead of replaying +/// as the default Squareness. Squareness, explicit or by default, still round-trips. +#[test] +fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() { + for id in ["filter.other.minimum", "filter.other.maximum"] { + let round = Action { name: "Round".into(), steps: vec![(id.into(), json!({"radius": 2, "preserve": "roundness"}))], ..Default::default() }; + assert!(export("Audit", &[&round], &[], false).is_err(), "{id}: strict export refuses it"); + let (_, skipped) = export("Audit", &[&round], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Round".into(), step: 0, id: id.into() }], "{id}"); + for p in [json!({"radius": 2}), json!({"radius": 2, "preserve": "squareness"})] { + let square = Action { name: "Square".into(), steps: vec![(id.into(), p.clone())], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&square], &[], false).unwrap(); + assert!(skipped.is_empty(), "{id} {p}"); + let imported = import(&bytes).unwrap(); + let (got, params) = &imported.actions[0].steps[0]; + assert_eq!((got.as_str(), params["radius"].as_f64()), (id, Some(2.0)), "{id} {p}"); + assert!(params.get("preserve").is_none_or(|v| v == "squareness"), "{id} {p}: {params}"); + } + } +} + +/// #7515: a Transform Selection's interpolation survives the round trip as a layer transform's +/// does. #7516-#7519: a reference point other than the centre, a skew or a warp can't be written, +/// so the step is refused instead of replaying as a plain scale. +#[test] +fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + let p = json!({"dx": 0.7, "dy": 0, "scaleX": 140, "scaleY": 80, "interpolation": "nearest"}); + let (bytes, skipped) = export("Audit7", &[&one("select.transformSelection", p.clone())], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("select.transformSelection".to_string(), p)]); + for id in ["select.transformSelection", "edit.transform"] { + for p in [ + json!({"scaleX": 140, "reference": "topLeft"}), + json!({"scaleX": 140, "reference": [3, 4]}), + json!({"scaleX": 140, "skewX": 12}), + json!({"scaleX": 140, "skewY": -5}), + json!({"warp": "arc"}), + json!({"corners": [[0, 0], [10, 0], [10, 10], [0, 10]]}), + ] { + let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err(); + assert!(err.contains("no Photoshop equivalent") && err.contains(&format!("`{id}`")), "{id} {p}: {err}"); + let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}"); + } + let carried = json!({"scaleX": 140, "reference": "center", "skewX": 0, "skewY": 0}); + assert!(export("Audit7", &[&one(id, carried.clone())], &[], false).is_ok(), "{id} {carried}"); + } +} + +/// #7540-#7542: Feather, Contract and Smooth (and Expand) with Apply Effect at Canvas Bounds, and +/// #7543-#7546: a pattern Paint Bucket with a chosen pattern, scale, angle or phase, can't be +/// written, so strict export refuses them. Their defaults still export and round-trip. +#[test] +fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] { + assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}"); + let plain = json!({"radius": 2, "applyAtCanvasBounds": false}); + let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]); + } + let bucket = |extra: Value| { + let mut p = json!({"x": 5, "y": 5, "contents": "pattern", "opacity": 70, "tolerance": 80}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + one("paint.bucket", p) + }; + for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}"); + } + for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] { + assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}"); + } +} + +/// #7502, #7512, #7513, #7514: a New Layer step that clips to the layer below, has a colour +/// label, fills with the neutral colour or goes below the active layer isn't exported, since the +/// descriptor carries only its name, opacity and mode. Those options at their defaults still +/// export, and the step round-trips. +#[test] +fn new_layer_options_the_descriptor_cannot_carry_refuse_strict_export() { + let new_layer = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.new.layer".into(), p)], ..Default::default() }; + for p in [ + json!({"name": "New", "clipToPrevious": true}), + json!({"name": "New", "color": "red"}), + json!({"name": "New", "mode": "Overlay", "fillNeutral": true}), + json!({"name": "New", "position": "below"}), + json!({"name": "New", "clipToPrevious": 1}), + ] { + assert!(export("Audit7", &[&new_layer(p.clone())], &[], false).is_err(), "{p}"); + let (_, skipped) = export("Audit7", &[&new_layer(p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.new.layer".into() }], "{p}"); + } + let defaults = + json!({"name": "New", "mode": "Multiply", "opacity": 0.6, "clipToPrevious": false, "color": "none", "fillNeutral": false, "position": "above"}); + let (bytes, skipped) = export("Audit7", &[&new_layer(defaults)], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("layer.new.layer".to_string(), json!({"name": "New", "mode": "Multiply", "opacity": 0.6}))]); +} + +/// Options the exported descriptors can't carry refuse strict export (and a partial export skips +/// the step) instead of exporting a step that replays differently; the forms they can carry still +/// export (#7196, #7246, #7287, #7503, #7506, #7507). +#[test] +fn options_the_descriptors_cannot_carry_refuse_strict_export() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + let refused = [ + ("layer.newFillLayer.solidColor", json!({"color": "#c0603080"})), + ("layer.newFillLayer.solidColor", json!({"color": "teal"})), + ("file.new", json!({"width": 24, "height": 18, "background": "black"})), + ("file.new", json!({"width": 24, "height": 18, "background": "#ab2233"})), + ("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor", "backgroundColor": [0.2, 0.4, 0.6]})), + ("select.loadSelection", json!({"channel": "Alpha 1", "invert": true})), + ("select.loadSelection", json!({"channel": "Alpha 1", "operation": "subtract"})), + ("layer.setProps", json!({"opacity": 0.5, "locked": true})), + ("layer.setProps", json!({"opacity": 0.5, "clipped": true})), + ("layer.setProps", json!({"layers": [2, 3], "opacity": 0.5})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483648_u64})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 4294967295_u64})), + ]; + for (id, p) in refused { + let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err(); + assert!(err.contains(&format!("`{id}`")), "{id} {p}: the error names the step: {err}"); + let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}"); + } + let carried = [ + ("layer.groupLayers", json!({"name": "Group"})), + ("layer.newFillLayer.solidColor", json!({"color": "#c06030"})), + ("file.new", json!({"width": 24, "height": 18, "background": "white"})), + ("file.new", json!({"width": 24, "height": 18, "background": "transparent"})), + ("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor"})), + ("select.loadSelection", json!({"channel": "Alpha 1", "invert": false, "operation": "new"})), + // A named target (a numeric one is #7248, #7262). + ("layer.setProps", json!({"layer": "Pixels", "opacity": 0.5, "visible": true})), + ("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483647})), + ]; + for (id, p) in carried { + let (bytes, skipped) = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + assert!(skipped.is_empty(), "{id} {p}"); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps.first().map(|(i, _)| i.as_str()), Some(id), "{p}"); + } +} + +/// Photoshop's Close has no document target: a Close of a specific document must not be +/// exported as a plain Close, which would close the active document on playback. +#[test] +fn close_with_document_target_is_not_exported_as_plain_close() { + let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() }; + assert!(export("Audit4", &[&targeted], &[], false).is_err()); + let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]); + let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() }; + let (bytes, skipped) = export("Audit4", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); +} + +/// A translation-only Free Transform with a fractional offset must not collapse into an +/// integer `layer.translate`: the sub-pixel placement and interpolation have to survive. +#[test] +fn fractional_translation_only_transform_keeps_offset_and_interpolation() { + let params = json!({"dx": 0.5, "dy": 0, "interpolation": "bilinear"}); + let d = transform_to("edit.transform", ¶ms).unwrap(); + let file = AtnFile { + name: UnicodeString::new_nul("Frac"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imp.unsupported.is_empty()); + let steps = &imp.actions[0].steps; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "edit.transform"); + assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); + assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); +} + +#[test] +fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + + let parse_first_desc = |v: &Value| -> Descriptor { + let bytes = photocraft_paint::tile::b64_decode(v["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&bytes).unwrap(); + file.actions[0].items[0].descriptor.clone().unwrap() + }; + + // Named layer reference exports successfully with Name reference to "A" + s.actions.list.push(Action::new("DeleteNamed", vec![("layer.delete".into(), json!({"layer": "A"}))])); + let saved_named = s.execute("actions.save", json!({"action": "DeleteNamed"})).unwrap(); + assert_eq!(saved_named["steps"], 1); + let named_desc = parse_first_desc(&saved_named); + match get_ref(&named_desc, "null").unwrap() { + [ReferenceItem::Name { name, .. }] => assert_eq!(name.to_string_lossy(), "A"), + other => panic!("expected Name reference to A, got {other:?}"), + } + + // Omitted layer reference (target selection) exports successfully with Trgt enumerated reference + s.actions.list.push(Action::new("DeleteTarget", vec![("layer.delete".into(), json!({}))])); + let saved_target = s.execute("actions.save", json!({"action": "DeleteTarget"})).unwrap(); + assert_eq!(saved_target["steps"], 1); + let target_desc = parse_first_desc(&saved_target); + match get_ref(&target_desc, "null").unwrap() { + [ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")), + other => panic!("expected Trgt enumerated reference, got {other:?}"), + } + + // Explicit null layer reference also exports successfully as target selection with Trgt enumerated reference + s.actions.list.push(Action::new("DeleteNull", vec![("layer.delete".into(), json!({"layer": null}))])); + let saved_null = s.execute("actions.save", json!({"action": "DeleteNull"})).unwrap(); + assert_eq!(saved_null["steps"], 1); + let null_desc = parse_first_desc(&saved_null); + match get_ref(&null_desc, "null").unwrap() { + [ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")), + other => panic!("expected Trgt enumerated reference, got {other:?}"), + } + + // Explicit numeric ID reference cannot be silently converted to current target in strict export + s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); + let before_actions = s.actions.list.clone(); + let before_rev = s.active().unwrap().revision; + let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"})); + assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target"); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); + let err_msg = err_delete.unwrap_err().to_string(); + assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}"); + + // Same rejection and state invariance for remaining commands with numeric layer reference + for (name, id, params) in [ + ("DuplicateNumeric", "layer.duplicate", json!({"layer": 42, "name": "Copy"})), + ("DuplicateNewDocNumeric", "layer.duplicate", json!({"layer": 42, "document": "new"})), + ("TranslateNumeric", "layer.translate", json!({"layer": 42, "dx": 5, "dy": 5})), + ("SetPropsNumeric", "layer.setProps", json!({"layer": 42, "opacity": 0.5})), + ("HideNumeric", "layer.hideLayers", json!({"layer": 42})), + ("TransformNumeric", "edit.transform", json!({"layer": 42, "dx": 5, "dy": 5})), + ("RenameNumeric", "layer.renameLayer", json!({"layer": 42, "name": "Renamed"})), + ("LabelNumeric", "layer.setLabelColor", json!({"layer": 42, "color": "red"})), + ("ShowNumeric", "layer.showLayers", json!({"layer": 42})), + ("SelectNumeric", "layer.select", json!({"layer": 42})), + ("BringToFrontNumeric", "layer.arrange.bringToFront", json!({"layer": 42})), + ("SendToBackNumeric", "layer.arrange.sendToBack", json!({"layer": 42})), + ("BringForwardNumeric", "layer.arrange.bringForward", json!({"layer": 42})), + ("SendBackwardNumeric", "layer.arrange.sendBackward", json!({"layer": 42})), + ] { + s.actions.list.push(Action::new(name, vec![(id.into(), params)])); + let before_actions = s.actions.list.clone(); + let before_rev = s.active().unwrap().revision; + assert!(s.execute("actions.save", json!({"action": name})).is_err()); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); + } + + // With skipUnsupported: true, numeric layer references are skipped + let skipped = s.execute("actions.save", json!({"action": "DeleteNumeric", "skipUnsupported": true})).unwrap(); + assert_eq!(skipped["steps"], 0); + assert_eq!(skipped["skipped"][0]["id"], "layer.delete"); + + // Blend-mode shortcut records layer.setProps with active layer ID, which normalises to the target form + let active_id = s.active().unwrap().active_layer.unwrap().0; + s.execute("actions.record", json!({"name": "RecordBlend"})).unwrap(); + s.execute("layer.setProps", json!({"layer": active_id, "blend": "multiply"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved_blend = s.execute("actions.save", json!({"action": "RecordBlend"})).unwrap(); + assert_eq!(saved_blend["steps"], 1, "recorded blend shortcut step with active layer id must export successfully as target layer"); + assert_eq!(saved_blend["skipped"].as_array().unwrap().len(), 0); + + // Record explicit layer.delete of the sole selected layer in a two-layer document + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + let second_id = s.active().unwrap().active_layer.unwrap().0; + s.execute("actions.record", json!({"name": "RecordDelete"})).unwrap(); + s.execute("layer.delete", json!({"layer": second_id})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved_delete = s.execute("actions.save", json!({"action": "RecordDelete"})).unwrap(); + assert_eq!(saved_delete["steps"], 1, "recorded explicit delete of sole selected layer must export successfully as target delete"); + assert_eq!(saved_delete["skipped"].as_array().unwrap().len(), 0); +} + +/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it +/// silently replays the rotation with the default bicubic resampling. +#[test] +fn arbitrary_rotation_keeps_interpolation_through_atn() { + for params in [ + json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}), + json!({"angle": 17, "direction": "ccw"}), + ] { + let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]); + } +} + +/// Filter › Stylize › Emboss saves as Photoshop's `Embs` event with integer `Angl`, `Hght` and +/// `Amnt`, and loads back as the same step (#7946: it was skipped, so a portable action lost it). +#[test] +fn emboss_saves_as_photoshops_event_and_loads_back() { + let mut s = Session::new(); + let params = json!({"angle": 130, "height": 1, "amount": 70}); + s.actions.list.push(Action::new("Pore", vec![("filter.stylize.emboss".into(), params.clone())])); + let r = s.execute("actions.save", json!({"action": "Pore"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + let step = &file.actions[0].items[0]; + assert_eq!(step.event.id(), "Embs"); + let d = step.descriptor.as_ref().unwrap(); + for (key, want) in [("Angl", 130), ("Hght", 1), ("Amnt", 70)] { + assert_eq!(d.get(key), Some(&D::Integer(want)), "{key}"); + } + let mut t = Session::new(); + t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(t.actions.list[0].steps, vec![("filter.stylize.emboss".to_string(), params)]); +} + +/// A filter recorded against a layer mask must not export as a plain filter that edits the +/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. +#[test] +fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { + let masked = Action { + name: "Probe".into(), + steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], + ..Default::default() + }; + assert!(export("Audit", &[&masked], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); + for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { + let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); + } +} + +/// Photoshop's Duplicate Layer has no in-place flag: an in-place duplicate (the artboard copy +/// that stays on the original) must not be exported as a plain duplicate, which places the +/// copy beside the original on playback. +#[test] +fn duplicate_in_place_is_not_exported_as_plain_duplicate() { + let in_place = Action { name: "Probe".into(), steps: vec![("layer.duplicate".into(), json!({"inPlace": true}))], ..Default::default() }; + assert!(export("Audit5", &[&in_place], &[], false).is_err()); + let (_, skipped) = export("Audit5", &[&in_place], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.duplicate".into() }]); + for params in [json!({}), json!({"inPlace": false})] { + let plain = Action { name: "Plain".into(), steps: vec![("layer.duplicate".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); + } +} + +/// A Play Action that starts the called action at a later step must not export as a call of the +/// whole action: that would replay the steps the recording deliberately skipped. +#[test] +fn partial_play_action_is_not_exported_as_whole_action_call() { + let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() }; + assert!(export("Set", &[&partial], &[], false).is_err()); + let (_, skipped) = export("Set", &[&partial], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]); + for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] { + let whole = Action { name: "Parent".into(), steps: vec![("actions.play".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]); + } +} + +/// #7646: an action's function key keeps Photoshop's Shift and Command options through +/// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. +#[test] +fn function_keys_keep_shift_and_command() { + let mut file = sample(); + file.actions[1].function_key = 5; + file.actions[1].shift = true; + file.actions[1].command = true; + file.actions[0].shift = true; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "Shift+F3"}, {"action": "Finish", "key": "Cmd+Shift+F5"}])); + let shortcuts = &s.prefs().shortcuts; + assert_eq!(shortcuts.get("actions.play:Soften").map(String::as_str), Some("Shift+F3")); + assert_eq!(shortcuts.get("actions.play:Finish").map(String::as_str), Some("Cmd+Shift+F5")); + let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let keys: Vec<_> = saved.actions.iter().map(|a| (a.function_key, a.shift, a.command)).collect(); + assert_eq!(keys, [(3, true, false), (5, true, true)]); + + assert_eq!(super::parse_function_key("Cmd+F12"), Some((12, false, true))); + assert_eq!(super::parse_function_key("Shift+Cmd+F2"), Some((2, true, true))); + for bad in ["F16", "F0", "Alt+F2", "Cmd+Cmd+F2", "Shift+", "Cmd+K", ""] { + assert_eq!(super::parse_function_key(bad), None, "{bad}"); + } + assert_eq!(super::function_key_name(7, false, false), "F7"); +} + +/// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays +/// the missing key differently (checked against Photoshop): Canvas Size anchors top left, +/// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket +/// step without a tolerance doesn't play at all. +#[test] +fn defaults_photoshop_plays_differently_are_written() { + 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 bucket = exported("paint.bucket", json!({"x": 1, "y": 2})); + assert_eq!((get_num(&bucket, "Tlrn"), get_bool(&bucket, "AntA")), (Some(32.0), Some(true))); + let canvas = exported("image.canvasSize", json!({"width": 10, "height": 10})); + assert_eq!((get_enum(&canvas, "Hrzn").as_deref(), get_enum(&canvas, "Vrtc").as_deref()), (Some("Cntr"), Some("Cntr"))); + let apply = exported("image.applyImage", json!({})); + assert_eq!(get_obj(&apply, "With").and_then(|w| get_enum(w, "Clcl")).as_deref(), Some("Mltp")); + let balance = exported("image.adjustments.colorBalance", json!({"midtones": [10, 0, 0]})); + assert_eq!(get_bool(&balance, "PrsL"), Some(true)); + assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3)); +} + +/// 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() { + assert!(export_item("tools.setColors", &json!({"foreground": "#ff0000", "background": "#0000ff"}), "S").is_none()); + assert!(export_item("tools.setColors", &json!({"foregroundCmyk": [0, 0, 0, 100], "background": "#0000ff"}), "S").is_none()); + assert!(export_item("tools.setColors", &json!({"background": "#0000ff"}), "S").is_some()); +} + +/// Photoshop's reset of an adjustment layer (`set` with an empty `T`) stays a reset: `kind` +/// only joins params that edit something, as any key makes `layer.setAdjustment` merge. +#[test] +fn adjustment_layer_reset_still_resets() { + let set = |t: Descriptor| desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t)); + let reset = set(desc("HStr").with("presetKind", enm("presetKindType", "presetKindDefault"))); + let edit = set(desc("ClrB").with("MdtL", D::List(vec![long(10.0), long(0.0), long(0.0)]))); + let file = AtnFile { + name: UnicodeString::new_nul("Reset"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![item("setd", "Set", Some(reset)), item("setd", "Set", Some(edit))], + ..Default::default() + }], + warnings: vec![], + }; + let steps = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0).steps; + assert_eq!(steps[0], ("layer.setAdjustment".to_string(), json!({}))); + assert_eq!(steps[1].1, json!({"midtones": [10, 0, 0], "kind": "colorBalance"})); +} + +/// A Free Transform recorded against an unlinked mask or an alpha channel must not export as a +/// layer transform that moves the layer's pixels on replay (#7195). +#[test] +fn transform_with_mask_or_channel_target_is_not_exported_as_layer_transform() { + for target in [json!("mask"), json!({"channel": 0}), json!("quickMask")] { + let a = Action { + name: "Probe".into(), + steps: vec![("edit.transform".into(), json!({"target": target, "dx": 3, "dy": 2, "scaleX": 100, "interpolation": "nearest"}))], + ..Default::default() + }; + assert!(export("Targets", &[&a], &[], false).is_err(), "{target} exported"); + let (_, skipped) = export("Targets", &[&a], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "edit.transform".into() }]); + } + for params in [ + json!({"dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}), + json!({"target": "pixels", "dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}), + ] { + let plain = Action { name: "Plain".into(), steps: vec![("edit.transform".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Targets", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("edit.transform".to_string(), json!({"dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}))]); + } + for form in [json!({"quad": [[0, 0], [1, 0], [1, 1], [0, 1]]}), json!({"matrix": [1, 0, 0, 1, 0, 0]}), json!({"rect": [0, 0, 1, 1]})] { + assert!(transform_to("edit.transform", &form).is_none()); + } +} + +/// Apply Image's optional blending mask has no `.atn` mapping: a strict export must refuse the +/// step rather than drop the mask and widen the edit to the whole layer. +#[test] +fn apply_image_with_blending_mask_is_not_exported_without_its_mask() { + let source = json!({"layer": "Pixels", "channel": "green", "invert": true}); + for params in [json!({"source": source, "blending": "normal", "mask": {"channel": 0}}), json!({"source": source, "blending": "normal", "maskChannel": 0})] { + let masked = Action { name: "Masked".into(), steps: vec![("image.applyImage".into(), params.clone())], ..Default::default() }; + assert!(export("Audit5", &[&masked], &[], false).is_err(), "{params}"); + let (_, skipped) = export("Audit5", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Masked".into(), step: 0, id: "image.applyImage".into() }]); + } + for params in [json!({"source": source, "blending": "normal"}), json!({"source": source, "blending": "normal", "mask": {"channel": "none"}})] { + let plain = Action { name: "Plain".into(), steps: vec![("image.applyImage".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("image.applyImage".to_string(), json!({"source": source, "blending": "normal"}))]); + } +} + +fn image_size_round_trip(params: &Value) -> Option { + let item = export_item("image.imageSize", params, "S")?; + let e = event_for(&item.event).unwrap(); + match (e.from)(item.descriptor.as_ref().unwrap()) { + Mapped::Steps(steps) => Some(steps[0].1.clone()), + _ => panic!("image size descriptor did not import as steps"), + } +} + +/// A step with brush strokes and Sample All Layers is refused by `refineSelectionEdge`; it must +/// not fall through to `smartBrushWorkspace`, which has no field for the strokes either. +#[test] +fn refine_edge_with_strokes_and_sample_all_layers_is_not_exported_without_the_strokes() { + let stroke = json!({"points": [[5, 4]], "size": 4, "mode": "add"}); + let brushed = Action { + name: "Probe".into(), + steps: vec![("select.refineEdge".into(), json!({"radius": 1, "sampleAllLayers": true, "refineStrokes": [stroke]}))], + ..Default::default() + }; + assert!(export("Audit", &[&brushed], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); + let plain = Action { + name: "Plain".into(), + steps: vec![("select.refineEdge".into(), json!({"radius": 1, "sampleAllLayers": true, "refineStrokes": []}))], + ..Default::default() + }; + assert!(export("Audit", &[&plain], &[], false).is_ok()); +} + +/// Photoshop's Select and Mask descriptor as mapped has no field for the Refine Edge Brush, +/// so a step with brush strokes must not export as a plain Select and Mask without them. +#[test] +fn refine_edge_with_brush_strokes_is_not_exported_without_them() { + for mode in ["add", "subtract"] { + let brushed = Action { + name: "Probe".into(), + steps: vec![("select.refineEdge".into(), json!({"radius": 1, "refineStrokes": [{"points": [[5, 4]], "size": 4, "mode": mode}]}))], + ..Default::default() + }; + assert!(export("Audit", &[&brushed], &[], false).is_err(), "{mode}"); + let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); + } + for params in [json!({"radius": 1}), json!({"radius": 1, "refineStrokes": []}), json!({"radius": 1, "refineStrokes": null})] { + let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("select.refineEdge".to_string(), json!({"radius": 1}))]); + } +} + +#[test] +fn image_size_resolution_only_does_not_gain_keep_print_size() { + // The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not + // export as a descriptor that imports back as keep-print-size. + let params = json!({"resolution": 144, "resample": "nearest"}); + assert_eq!(image_size_round_trip(¶ms), None); +} + +#[test] +fn image_size_intentional_controls_still_round_trip() { + for params in [ + json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true}), + json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest"}), + ] { + assert_eq!(image_size_round_trip(¶ms), Some(params.clone())); + } +} + +#[test] +fn recorded_layer_select_exports_to_atn() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let l = s.execute("layer.new.layer", json!({"name": "Wanted"})).unwrap(); + let wanted_id = l["layer"].as_u64().unwrap(); + s.execute("actions.record", json!({"name": "SelectWanted"})).unwrap(); + s.execute("layer.select", json!({"layer": wanted_id})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + + let action = s.execute("actions.get", json!({"action": "SelectWanted"})).unwrap(); + assert_eq!(action["steps"][0], json!(["layer.select", {"name": "Wanted"}])); + + // Strict export should succeed and produce bytes without skipUnsupported + let saved = s.execute("actions.save", json!({"action": "SelectWanted"})).unwrap(); + assert_eq!(saved["steps"], 1); + assert_eq!(saved["skipped"], json!([])); + + // When exported with skipUnsupported: true, it shouldn't skip the step either + let saved_skip = s.execute("actions.save", json!({"action": "SelectWanted", "skipUnsupported": true})).unwrap(); + assert_eq!(saved_skip["steps"], 1); + assert_eq!(saved_skip["skipped"], json!([])); + + // If an action has an occurrence target (duplicate names), it cannot be represented unambiguously in .atn + let bad_action = Action::new("Dup", vec![("layer.select".into(), json!({"name": "Wanted", "occurrence": 1}))]); + let err = export("Set", &[&bad_action], &[], false); + assert!(err.is_err(), "occurrence without representation must fail strict export"); +} + +#[test] +fn select_rect_mode_strict_export_rejects_unrepresentable_modes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + + // Default or "replace" mode succeeds + s.actions.list.push(Action::new("ReplaceAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "replace"}))])); + let saved = s.execute("actions.save", json!({"action": "ReplaceAct"})).unwrap(); + assert_eq!(saved["steps"], 1); + + // "add" mode cannot be silently converted to replace (set) in strict export + s.actions.list.push(Action::new("AddAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "add"}))])); + let before_actions = s.actions.list.clone(); + let err = s.execute("actions.save", json!({"action": "AddAct"})); + assert!(err.is_err(), "strict export must reject select.rect with add mode rather than converting to replace"); + assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + + // "subtract" mode cannot be silently converted to replace either + s.actions.list.push(Action::new("SubtractAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "subtract"}))])); + let before_actions_sub = s.actions.list.clone(); + let err_subtract = s.execute("actions.save", json!({"action": "SubtractAct"})); + assert!(err_subtract.is_err(), "strict export must reject select.rect with subtract mode rather than converting to replace"); + assert_eq!(s.actions.list, before_actions_sub, "actions list must remain unchanged after rejected save"); + + // With skipUnsupported: true, unrepresentable modes are reported in skipped + let skipped = s.execute("actions.save", json!({"action": "AddAct", "skipUnsupported": true})).unwrap(); + assert_eq!(skipped["steps"], 0); + assert_eq!(skipped["skipped"][0]["id"], "select.rect"); +} + +#[test] +fn radial_blur_off_center_strict_export_refuses_silent_recentering() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + + // Default center (omitted) exports successfully + s.actions.list.push(Action::new("DefaultCenter", vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good"}))])); + let saved = s.execute("actions.save", json!({"action": "DefaultCenter"})).unwrap(); + assert_eq!(saved["steps"], 1); + + // Explicit (0.5, 0.5) center also exports successfully + s.actions.list.push(Action::new( + "ExplicitDefaultCenter", + vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "centerX": 0.5, "centerY": 0.5}))], + )); + let saved_exp = s.execute("actions.save", json!({"action": "ExplicitDefaultCenter"})).unwrap(); + assert_eq!(saved_exp["steps"], 1); + + // Off-center radial blur cannot be silently recentered in strict export + s.actions.list.push(Action::new( + "OffCenter", + vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "centerX": 0.1, "centerY": 0.2}))], + )); + let before_actions = s.actions.list.clone(); + let err = s.execute("actions.save", json!({"action": "OffCenter"})); + assert!(err.is_err(), "strict export must reject off-center radial blur rather than silently recentering it"); + assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + + // With skipUnsupported: true, off-center radial blur is skipped + let skipped = s.execute("actions.save", json!({"action": "OffCenter", "skipUnsupported": true})).unwrap(); + assert_eq!(skipped["steps"], 0); + assert_eq!(skipped["skipped"][0]["id"], "filter.blur.radialBlur"); + + // Radial blur targeting a mask must not export as a plain filter that edits pixels + s.actions + .list + .push(Action::new("MaskTarget", vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "target": "mask"}))])); + let before_actions = s.actions.list.clone(); + let err_mask = s.execute("actions.save", json!({"action": "MaskTarget"})); + assert!(err_mask.is_err(), "strict export must reject radial blur with non-pixel target"); + assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + + let skipped_mask = s.execute("actions.save", json!({"action": "MaskTarget", "skipUnsupported": true})).unwrap(); + assert_eq!(skipped_mask["steps"], 0); + assert_eq!(skipped_mask["skipped"][0]["id"], "filter.blur.radialBlur"); + + // Explicit pixels target exports successfully + s.actions.list.push(Action::new( + "PixelsTarget", + vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "target": "pixels"}))], + )); + let saved_pixels = s.execute("actions.save", json!({"action": "PixelsTarget"})).unwrap(); + assert_eq!(saved_pixels["steps"], 1); +} + +/// A crop with W x H x Resolution output options must not export as a plain rectangle crop +/// that keeps the old size and resolution on replay: the mapper has no field for them. +#[test] +fn crop_with_output_size_or_resolution_is_not_exported_as_plain_crop() { + for params in [ + json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6, "resolution": 144, "resample": "nearest"}), + json!({"x": 1, "y": 1, "width": 4, "height": 3, "resolution": 144}), + json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6}), + ] { + let sized = Action { name: "Probe".into(), steps: vec![("image.crop".into(), params)], ..Default::default() }; + assert!(export("Audit", &[&sized], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&sized], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "image.crop".into() }]); + } + let plain = Action { name: "Plain".into(), steps: vec![("image.crop".into(), json!({"x": 1, "y": 1, "width": 4, "height": 3}))], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("image.crop".to_string(), json!({"x": 1, "y": 1, "width": 4, "height": 3}))]); +} + +/// `image.imageSize` with `resample: "none"` ignores width, height and percent; the `.atn` +/// round trip must not turn them into a pixel resize (#7189). +#[test] +fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { + let event = event_for(&export_item("image.imageSize", &json!({"resolution": 72}), "S").expect("exports").event).expect("a known event"); + let back = |p: Value| { + let item = export_item("image.imageSize", &p, "S")?; + let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") }; + let mut back = steps[0].1.clone(); + normalise(&mut back); + Some(back) + }; + for sized in [json!({"width": 12, "height": 10}), json!({"width": 12}), json!({"percent": 200}), json!({"width": 12, "percent": 50, "height": 3})] { + let mut p = sized.clone(); + p["resolution"] = json!(144); + p["resample"] = json!("none"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none"})), "{p}"); + } + // A step with nothing left to record is refused rather than exported as an empty (unsupported) event. + assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None); + // Real resizes are untouched. + assert_eq!(back(json!({"width": 12, "height": 10, "resample": "bicubic"})), Some(json!({"width": 12, "height": 10, "resample": "bicubic"}))); +} + +/// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written +/// and read as `Hue `: exported actions failed in Photoshop, and Photoshop-recorded ones lost +/// their hue on import with no warning. PhotoCraft's own older files (`Hue `) still read. +#[test] +fn hue_saturation_uses_photoshops_hue_key() { + let params = json!({"colorize": false, "hue": 30, "saturation": 20, "lightness": -7, "greens": {"hue": -12, "saturation": 0, "lightness": 0, "range": [75, 105, 135, 165]}}); + let a = Action::new("H", vec![("image.adjustments.hueSaturation".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let d = file.actions[0].items[0].descriptor.clone().expect("a descriptor"); + let entries: Vec = + get_list(&d, "Adjs").expect("Adjs").iter().filter_map(|e| if let D::Descriptor(e) = e { Some(e.clone()) } else { None }).collect(); + assert_eq!(entries.len(), 2); + assert_eq!(get_num(&entries[0], "H "), Some(30.0), "the master hue under H"); + assert_eq!(get_num(&entries[1], "H "), Some(-12.0), "a range's hue under H"); + assert!(entries.iter().all(|e| get_num(e, "Hue ").is_none()), "no Hue key"); + + // A Photoshop-recorded step (H) and an older PhotoCraft one (Hue ) both import with the hue. + let step = |key: &str| { + desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Clrz", D::Boolean(false)) + .with("Adjs", D::List(vec![D::Descriptor(desc("Hst2").with(key, long(30.0)).with("Strt", long(20.0)).with("Lght", long(-7.0)))])) + }; + for key in ["H ", "Hue "] { + let e = event_for(&item("HStr", "", None).event).expect("HStr imports"); + match (e.from)(&step(key)) { + Mapped::Steps(steps) => { + assert_eq!(steps[0].0, "image.adjustments.hueSaturation"); + assert_eq!(steps[0].1["hue"], json!(30.0), "{key:?}: {}", steps[0].1); + assert_eq!(steps[0].1["saturation"], json!(20.0)); + } + Mapped::Unsupported { .. } => panic!("{key:?} is unsupported"), + } + } + // Through a whole file, saved and loaded back: the hue survives. + let back = import(&bytes).unwrap(); + let mut p = back.actions[0].steps[0].1.clone(); + normalise(&mut p); + assert_eq!(p["hue"], json!(30), "{p}"); +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 4c78f90b34..2256a76cda 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -314,9 +314,11 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?; let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?; let mut steps = parse_steps(steps_v, cmd)?; - // The droplet's output folder replaces recorded saves, and view steps don't apply, as in batch. - steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id)); - check_known(&steps, cmd)?; + // As in batch, the droplet's output folder replaces recorded saves, which are kept to mark the + // result, and view steps don't apply. + steps.retain(|(id, _)| crate::file_cmds::batch_keeps_step(id)); + let commands: Vec<_> = steps.iter().filter(|(id, _)| !crate::file_cmds::batch_save_point(id)).cloned().collect(); + check_known(&commands, cmd)?; // Play Action steps need their actions when the droplet runs on its own (#2794). Not under // `actions`: the CLI reads that key as an action set. let called = crate::actions_cmds::called_actions(&s.actions.list, &mut steps); @@ -558,7 +560,7 @@ pub fn specs() -> Vec { "file.automate.createDroplet", "Create Droplet…", &["File", "Automate"], - r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action steps call are saved in the droplet)"##, + r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action and Conditional steps call are saved in the droplet)"##, native, create_droplet ), diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 06d0e88f36..8bea15c69a 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -166,6 +166,24 @@ fn droplets_are_written_and_run() { assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err()); } +/// A droplet keeps the action's recorded saves, which mark the result as in Batch, so one made from +/// an action ending in Save As and Close writes its files instead of failing on every one. +#[test] +fn droplets_keep_the_recorded_save_that_marks_the_result() { + let dir = tmp("droplet-save-close"); + let inputs = images(&dir, 1, 4, 2); + let mut s = Session::new(); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {}]]); + let p = json!({"path": format!("{dir}/Invert"), "steps": steps, "format": "png", "output": format!("{dir}/out"), "shim": false}); + let dp = s.execute("file.automate.createDroplet", p).unwrap()["path"].as_str().unwrap().to_string(); + let r = s.execute("file.automate.runDroplet", json!({"droplet": dp, "input": [dir.clone()]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); + let out = photocraft_io::import("img0.png", &std::fs::read(format!("{dir}/out/img0.png")).unwrap()).unwrap().document; + let px = photocraft_compose::render(&out, photocraft_geom::Rect::new(1, 1, 2, 2)).px[0]; + assert!((px[0] - 235.0 / 255.0).abs() < 1e-3, "img0's gray 20 inverted: {px:?}"); +} + #[cfg(unix)] #[test] fn droplet_shim_passes_shell_metacharacters_as_a_literal_path() { @@ -252,6 +270,38 @@ fn droplets_carry_the_actions_their_play_steps_call() { assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); } +#[test] +fn droplets_carry_the_actions_their_conditional_steps_call() { + use crate::actions_cmds::Action; + let dir = tmp("droplet-conditional"); + let inputs = images(&dir, 2, 40, 20); + let mut s = Session::new(); + let act = |name: &str, steps: Value| -> Action { serde_json::from_value(json!({"name": name, "steps": steps})).unwrap() }; + s.actions.list = vec![ + act("Unrelated", json!([["image.imageRotation.180", {}]])), + act("Rotate", json!([["image.imageRotation.90cw", {}]])), + // The then branch calls Rotate by index, as the panel records it. + act("Wide", json!([["actions.play", {"action": 1}]])), + act("Tall", json!([["image.imageRotation.180", {}]])), + ]; + let steps = json!([["actions.conditional", {"if": "landscape", "then": "Wide", "else": "Tall"}]]); + let out = format!("{dir}/out"); + let r = s + .execute("file.automate.createDroplet", json!({"path": format!("{dir}/Cond"), "steps": steps, "format": "png", "output": out, "shim": false})) + .unwrap(); + let dp = r["path"].as_str().unwrap().to_string(); + let v: Value = serde_json::from_slice(&std::fs::read(&dp).unwrap()).unwrap(); + let names: Vec<&str> = v["calledActions"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Rotate", "Wide", "Tall"], "both branches and what they call"); + + // A fresh session, as `photocraft-cli droplet` and the droplet shim use. + let mut fresh = Session::new(); + let r = fresh.execute("file.automate.runDroplet", json!({"droplet": dp, "input": [dir.clone()]})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); + let img = photocraft_codecs::decode(&std::fs::read(format!("{out}/img0.png")).unwrap()).unwrap(); + assert_eq!(img.dimensions(), (20, 40), "the landscape input took the then branch"); +} + #[test] fn batch_and_droplets_skip_recorded_view_steps() { let dir = tmp("view-steps"); diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index f2c9e28b2c..feb238cf40 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -133,6 +133,18 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Result { mix: pct(p, "mix", bm.mix * 100.0), flow: pct(p, "flow", bm.flow * 100.0), sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), + load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke), + clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke), }; let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); + let paint = color(p.get("color"), s.tools.foreground); let mut state = s.tools.mixer.clone(); - if load_after || state.reservoir.is_none() { - state.load(color(p.get("color"), s.tools.foreground)); - } + state.begin_stroke(paint); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { @@ -672,13 +684,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) })?; - if clean { - state.clean(); - } + state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; Ok(damage_json(s, dmg)) } +/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke +/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now +/// carries. +fn mixer_load(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.mixer.load"; + let paint = match p.get("color").filter(|v| !v.is_null()) { + None => Some(s.tools.foreground), + Some(Value::String(h)) => crate::commands::parse_hex(h), + Some(Value::Array(a)) => { + let c: Option> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect(); + match c.as_deref() { + Some(&[r, g, b]) => Some([r, g, b, 1.0]), + Some(&[r, g, b, a]) => Some([r, g, b, a]), + _ => None, + } + } + Some(_) => None, + }; + let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?; + s.tools.mixer.load(paint); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + +/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless +/// a different color is chosen first. Returns the paint the brush now carries. +fn mixer_clean(s: &mut Session, _: &Value) -> Result { + s.tools.mixer.clean(); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour /// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. @@ -793,13 +835,19 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result { if s.tools.presets.len() == before { return Err(bad(cmd, format!("no brush preset named `{name}`"))); } - if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) { + forget_deleted_preset(s, &name); + Ok(json!({ "count": s.tools.presets.len() })) +} + +/// After preset `name` was deleted (here or in Edit › Presets › Preset Manager): the current +/// brush no longer comes from a preset, and the library is saved. +pub(crate) fn forget_deleted_preset(s: &mut Session, name: &str) { + if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(name)) { s.tools.current_preset = None; // The remembered brush names the preset it was picked from: the name went away. s.tools.brush_changed(); } s.brush_presets_changed(); - Ok(json!({ "count": s.tools.presets.len() })) } /// Overwrite an existing preset with the current brush (or the current brush plus a `brush` @@ -864,8 +912,11 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { // Paint amount = darkness × alpha × selection (black = full paint, like Photoshop). let (w, h) = (area.width() as usize, area.height() as usize); // The sampled tip is one f32 buffer over the whole selection; a large selection must fail - // gracefully instead of aborting the process on allocation, so reserve it fallibly. - let mut v = crate::allocation::filled(w * h, 0.0, "defining a brush from the selection")?; + // gracefully instead of aborting the process on allocation, so reserve it fallibly. The + // multiply is checked too: on a 32-bit target (wasm32) it can overflow where the rest of + // this command is already fallible. + let len = w.checked_mul(h).ok_or_else(|| crate::allocation::error("defining a brush from the selection", "requested size overflowed"))?; + let mut v = crate::allocation::filled(len, 0.0, "defining a brush from the selection")?; let sc = sel.channels(); // The selection mask is a second buffer of the same size; it must fail gracefully too // (review of #5469), so read it through the fallible allocator. @@ -997,23 +1048,43 @@ pub fn remember_brush(s: &mut Session) { /// bitmap. `None` while a sampled bitmap too big to embed is current (only before the store /// externalizes it; see [`ref_tip`]), so the previous remembered brush stands. fn remembered_brush(s: &Session) -> Option { - let b = &s.tools.brush; + Some(json!({ "preset": s.tools.current_preset.clone(), "brush": remembered_settings(&s.tools.brush)? })) +} + +/// A brush as JSON for the preferences (the last brush, each painting tool's own brush): its +/// settings with stored tips as references, never their full bitmaps. `None` while a sampled +/// bitmap too big to embed is in it (only before the preset store externalizes it). +pub fn remembered_settings(b: &BrushSettings) -> Option { let (tip, dual, pattern) = (ref_tip(&b.tip)?, ref_tip(&b.dual_brush.tip)?, ref_pattern(&b.texture.pattern)?); let mut c = b.clone(); (c.tip, c.dual_brush.tip, c.texture.pattern) = (tip, dual, pattern); - Some(json!({ "preset": s.tools.current_preset.clone(), "brush": serde_json::to_value(&c).ok()? })) + serde_json::to_value(&c).ok() +} + +/// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is +/// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, +/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// they paint with their previews until `Session::attach_preset_store`). +pub fn restored_settings(s: &Session, v: &Value) -> Option { + if !v.is_object() { + return None; + } + let mut b = merge_brush(&BrushSettings::default(), v, "brush.restore").ok()?; + validate_brush(&b, "brush.restore").ok()?; + b.size = b.size.clamp(1.0, photocraft_paint::MAX_BRUSH_SIZE); + for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { + *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; + } + let _ = s.load_brush_tips(&mut b); + Some(b) } /// Restore `Preferences::last_brush` into the session at launch (a missing or malformed value /// keeps the default brush). Stored tips load when the preset store attaches. pub fn restore_brush(s: &mut Session) { let v = s.prefs().last_brush.clone(); - let Some(patch) = v.get("brush").filter(|b| b.is_object()) else { return }; - let Ok(mut b) = merge_brush(&BrushSettings::default(), patch, "brush.restore") else { return }; - if validate_brush(&b, "brush.restore").is_err() { - return; - } - let _ = s.load_brush_tips(&mut b); + let Some(b) = v.get("brush").and_then(|b| restored_settings(s, b)) else { return }; s.tools.brush = b; s.tools.current_preset = v.get("preset").and_then(Value::as_str).map(str::to_string); // What was just restored is exactly what the preferences hold: nothing new to write. @@ -1080,7 +1151,13 @@ pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value { _ => (a != b).then(|| b.clone()), } } - diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})) + let mut patch = diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})); + // This readback is derived from the axis flags. Sending it alongside a + // one-axis delta would also overwrite the other axis through the legacy alias. + if let Some(pose) = patch.get_mut("pose").and_then(Value::as_object_mut) { + pose.remove("overrideTilt"); + } + patch } /// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same @@ -1126,11 +1203,13 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, has_paintable, mixer_brush, true ), + spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true), + spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true), spec!( "paint.colorReplacement", "Color Replacement", @@ -1160,7 +1239,14 @@ pub fn specs() -> Vec { spec!("brush.presets.setCurrent", "Set Current Brush Preset", r##"{"name":string}"##, always, presets_set_current, true), spec!("brush.defineFromSelection", "Define Brush Preset…", r##"{"name":string}"##, has_selection_and_pixels, define_from_selection, true), spec!("brush.get", "Get Brush", "{}", always, |s, _| Ok(brush_json(&s.tools.brush)), false), - spec!("tools.setBrush", "Set Brush", r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged)}"##, always, set_brush, true), + spec!( + "tools.setBrush", + "Set Brush", + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + always, + set_brush, + true + ), ] } diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 5977603f94..87504dc0e9 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -371,6 +371,57 @@ fn resizing_a_brush_resizes_its_enabled_secondary_tip() { assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); } +/// Every way of changing the main size scales the Dual Brush tip as Photoshop does (#4809): `[` / +/// `]`, the options bar / Brushes panel / Alt+right-drag (`tools.setBrush` with only a size), down +/// to 1 px and back without losing the proportion to a floor. +#[test] +fn every_size_change_scales_the_dual_tip_and_keeps_its_proportion() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + let sizes = |s: &Session| (s.tools.brush.size, s.tools.brush.dual_brush.size); + s.execute("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (80.0, 16.0)); + s.execute("tools.increaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (100.0, 20.0)); + // A coalesced drag (options bar slider, Alt+right-drag) is a run of size-only patches. + for size in [60, 10, 1] { + s.execute("tools.setBrush", json!({"brush": {"size": size}, "coalesce": "drag"})).unwrap(); + } + assert_eq!(s.tools.brush.size, 1.0); + assert!((s.tools.brush.dual_brush.size - 0.2).abs() < 1e-5, "no 0.5 px floor: {}", s.tools.brush.dual_brush.size); + s.execute("tools.setBrush", json!({"brush": {"size": 100}})).unwrap(); + assert_eq!(s.tools.brush.size, 100.0); + assert!((s.tools.brush.dual_brush.size - 20.0).abs() < 1e-3, "back to 20 px: {}", s.tools.brush.dual_brush.size); + // Painting with the sub-pixel dual tip works (the renderer bounds it). + s.execute("tools.setBrush", json!({"brush": {"size": 1}})).unwrap(); + s.execute("paint.stroke", json!({"points": [[4, 16], [28, 16]]})).unwrap(); +} + +/// Picking a preset that captured its size sets both diameters from the preset: the dual tip is +/// not scaled on top of that. +#[test] +fn a_captured_preset_size_is_not_scaled_twice() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual40"})).unwrap(); + s.execute("tools.setBrush", json!({"size": 200})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 50.0); + s.execute("tools.setBrush", json!({"preset": "Dual40"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 10.0)); +} + +#[test] +fn bad_sizes_fail_and_leave_the_dual_tip_alone() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + for bad in [json!(1e30), json!("big"), json!([1]), json!({"x": 1}), json!(null)] { + let _ = s.execute("tools.setBrush", json!({"brush": {"size": bad}})); + assert_eq!(s.tools.brush.dual_brush.size, 20.0, "{bad}"); + assert_eq!(s.tools.brush.size, 100.0, "{bad}"); + } + assert!(s.execute("tools.setBrush", json!({"brush": {"size": 1e30}})).is_err()); +} + #[test] fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { let mut s = session(32, 32); @@ -462,6 +513,29 @@ fn define_brush_from_selection_survives_allocation_failure() { assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); } +#[test] +fn define_brush_from_selection_survives_failure_of_the_second_allocation() { + // Review of #5469: fail_next_for_test trips on the first buffer, so the second fallible + // call (the selection-mask read) did not have its own regression. Count the tip buffer as + // a clean attempt and fail the mask read instead; both report the same command purpose, + // and the command must still bow out before touching anything. + let mut s = session(40, 40); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + let pixels_before = surface(&s); + let brush_before = s.tools.brush.clone(); + let presets_before = s.tools.presets.clone(); + let history_before = s.active().unwrap().history.past_len(); + crate::allocation::fail_after_for_test(1); + let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err(), "an allocation failure must be reported, not panic"); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for defining a brush from the selection"))); + assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged"); + assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); + assert!(same_pixels(&pixels_before, &surface(&s), s.active().unwrap().doc.bounds()), "the document pixels are unchanged"); +} + #[test] fn works_on_cmyk_and_16_bit() { for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] { @@ -704,6 +778,57 @@ fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() { } } +#[test] +fn pose_axes_commands_keep_minimal_patches_legacy_alias_and_preset_replay() { + let mut s = session(100, 80); + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + s.execute("tools.setBrush", json!({"brush": {"pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, tilt_y: -18.0, ..input }); + let before = s.tools.brush.clone(); + s.execute("tools.setBrush", json!({"pose": {"overrideTiltX": false}})).unwrap(); + assert_eq!( + photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), + StrokePoint { tilt_y: -18.0, ..input }, + "turning X off retains Y override and live X input" + ); + let mixed = s.tools.brush.clone(); + assert_eq!(brush_patch(&before, &mixed), json!({"pose": {"overrideTiltX": false}}), "the derived combined flag must not enter an axis-only patch"); + let wire = s.execute("brush.get", json!({})).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], false); + assert_eq!(wire["pose"]["overrideTiltY"], true); + assert_eq!(wire["pose"]["overrideTilt"], false, "legacy readback reports whether both axes are overridden"); + s.execute("brush.presets.save", json!({"name": "Independent pose"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), input, "legacy false clears both axes even after axis editing"); + s.execute("tools.setBrush", json!({"preset": "Independent pose"})).unwrap(); + assert_eq!(s.tools.brush.pose, mixed.pose, "preset picking preserves independent state"); + // Explicit axis keys win over a legacy combined key in the same command. + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": true, "overrideTiltY": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, ..input }); + let mut replay = Session::new(); + for (id, params) in &s.journal { + replay.execute(id, params.clone()).unwrap(); + } + assert_eq!(replay.tools.brush, s.tools.brush, "recorded commands reproduce the same axes"); +} + +#[test] +fn pose_axes_invalid_flags_fail_atomically() { + let mut s = session(100, 80); + let before = s.tools.brush.clone(); + let n = s.journal.len(); + for pose in [ + json!({"overrideTiltX": "yes"}), + json!({"overrideTiltY": null}), + json!({"overrideTiltX": true, "overrideTiltY": 1}), + json!({"overrideTilt": "yes", "overrideTiltX": false}), + ] { + assert!(s.execute("tools.setBrush", json!({"brush": {"pose": pose}})).is_err()); + assert_eq!(s.tools.brush, before); + assert_eq!(s.journal.len(), n, "a failed update records no command"); + } +} + #[test] fn new_brush_controls_round_trip_through_set_brush() { let mut s = session(40, 40); @@ -763,6 +888,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() { assert!(rgba(&s, 190, 5)[3] > 0.95); } +/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all +/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off. +fn mixer_over_red() -> Session { + let mut s = session(200, 30); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false}); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap(); + s +} + +fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] { + s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap(); + rgba(s, 100, y) +} + +#[test] +fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({})).unwrap(); + let state = s.execute("brush.mixer.clean", json!({})).unwrap(); + assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}"); +} + +#[test] +fn the_automatic_clean_empties_the_reservoir() { + let mut s = mixer_over_red(); + // The brush was never loaded, so the first stroke takes the foreground. + assert!(mixer_row(&mut s, 5)[2] > 0.2); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}"); + // With the automatic Load on as well (the default), the brush is cleaned and then refilled + // after the stroke, so the next one starts with a full reservoir and no pickup. + s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap(); + mixer_row(&mut s, 25); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None)); +} + +#[test] +fn load_brush_after_a_clean_paints_the_foreground_again() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.clean", json!({})).unwrap(); + assert!(mixer_row(&mut s, 5)[2] < 0.01); + let state = s.execute("brush.mixer.load", json!({})).unwrap(); + assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0])); + assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue"); + // A color param loads that color instead of the foreground, and the next stroke paints it. + s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap(); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0)); + assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0])); + let c = mixer_row(&mut s, 25); + assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}"); +} + +#[test] +fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() { + let mut s = mixer_over_red(); + assert!(mixer_row(&mut s, 5)[2] > 0.2); + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + let c = mixer_row(&mut s, 15); + assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}"); + // The automatic Clean emptied it again, and the foreground did not change. + let c = mixer_row(&mut s, 25); + assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); +} + +#[test] +fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap(); + let before = s.tools.mixer.clone(); + for bad in [ + json!({"color": 5}), + json!({"color": "#zz0000"}), + json!({"color": "#ff"}), + json!({"color": [2, 0, 0]}), + json!({"color": [0, 0]}), + json!({"color": [0, 0, 0, 0, 0]}), + json!({"color": {"r": 1}}), + json!({"color": ["a", 0, 0]}), + ] { + assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}"); + assert_eq!(s.tools.mixer, before, "{bad}"); + } + // Clean Brush takes no params, so anything extra is ignored, without a document too. + let mut empty = Session::new(); + empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap(); + empty.execute("brush.mixer.load", json!({"color": null})).unwrap(); + assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground)); +} + #[test] fn coalesced_set_brush_calls_journal_once_per_gesture() { let mut s = session(20, 20); @@ -1041,6 +1261,20 @@ fn the_remembered_brush_restores_at_launch() { assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size); } +/// #6212: a remembered brush reads back clamped to its controls' ranges; one that fails the +/// `tools.setBrush` checks, or isn't a brush at all, doesn't read back. +#[test] +fn restored_brush_settings_are_clamped_or_rejected() { + let s = Session::new(); + let b = restored_settings(&s, &json!({"size": -4.0, "opacity": 7.0, "flow": -1.0, "hardness": 0.25, "smoothing": {"amount": 9.0}})).unwrap(); + assert_eq!((b.size, b.opacity, b.flow, b.hardness, b.smoothing.amount), (1.0, 1.0, 0.0, 0.25, 1.0)); + let round = restored_settings(&s, &remembered_settings(&b).unwrap()).unwrap(); + assert_eq!(round, b, "a remembered brush reads back as it was"); + for bad in [json!({"size": 1e30}), json!({"size": "big"}), json!(null), json!([1]), json!("brush")] { + assert!(restored_settings(&s, &bad).is_none(), "{bad}"); + } +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index 56663b400a..eada73490b 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -3,6 +3,9 @@ //! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound, //! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush. //! +//! `,` / `.` pick the previous or next brush preset and ⇧, / ⇧. the first or last, in the order +//! the Brushes panel lists them (Photoshop's "Previous Brush"… in Keyboard Shortcuts › Tools). +//! //! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean //! other things with other tools, so the shell gives them to it only for the painting tools //! (`move_mods::arrow_keys`). @@ -34,6 +37,82 @@ fn size(s: &mut Session, up: bool) -> Result { crate::brush_cmds::set_brush(s, &json!({ "brush": { "size": size } })) } +/// The Brushes panel's label for presets saved without a group: they share its group with presets +/// in a group of that name. +pub const UNGROUPED: &str = "My Brushes"; + +/// Brush preset indices in the Brushes panel's order: groups in order of first appearance (no +/// group is [`UNGROUPED`]), and in each group its folders where their first preset is, depth first +/// (as `brushes_tab` draws them). A preset its name can't select (an earlier one has the same name +/// up to case, and `tools.setBrush` picks that one) is left out afterwards, so stepping never +/// gets stuck. +fn panel_order(presets: &[photocraft_paint::BrushPreset]) -> Vec { + fn path(p: &photocraft_paint::BrushPreset) -> &[String] { + p.folder.get(..p.folder.len().min(crate::brush_preset_cmds::MAX_FOLDER_DEPTH)).unwrap_or_default() + } + fn flatten(presets: &[photocraft_paint::BrushPreset], items: &[usize], depth: usize, out: &mut Vec) { + let mut seen: Vec<&str> = Vec::new(); + for &i in items { + let Some(p) = presets.get(i) else { continue }; + match path(p).get(depth) { + None => out.push(i), + Some(name) if !seen.contains(&name.as_str()) => { + seen.push(name); + let inside: Vec = items.iter().copied().filter(|&j| presets.get(j).is_some_and(|q| path(q).get(depth) == Some(name))).collect(); + flatten(presets, &inside, depth + 1, out); + } + Some(_) => {} + } + } + } + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for (i, p) in presets.iter().enumerate() { + let group = if p.group.is_empty() { UNGROUPED } else { p.group.as_str() }; + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, v)) => v.push(i), + None => groups.push((group, vec![i])), + } + } + let mut out = Vec::with_capacity(presets.len()); + for (_, items) in &groups { + flatten(presets, items, 0, &mut out); + } + // Groups and folders are placed by every preset, as the panel places them; only then are the + // presets their names can't select left out. + out.retain(|&i| presets.get(i).is_some_and(|p| photocraft_paint::presets::find(presets, &p.name).is_some_and(|q| std::ptr::eq(q, p)))); + out +} + +/// Which way [`step_preset`] moves through the presets. +#[derive(Clone, Copy)] +enum Step { + Previous, + Next, + First, + Last, +} + +/// `,` / `.` / ⇧, / ⇧.: pick the previous, next, first or last brush preset (wrapping around), as +/// a click in the Brushes panel would (`tools.setBrush` with the preset). With no preset picked +/// yet, Next starts at the first and Previous at the last. +fn step_preset(s: &mut Session, step: Step) -> Result { + let order = panel_order(&s.tools.presets); + let n = order.len(); + if n == 0 { + return Err(EngineError::Other("there are no brush presets".into())); + } + let at = s.tools.current_preset.as_deref().and_then(|c| order.iter().position(|&i| s.tools.presets.get(i).is_some_and(|p| p.name.eq_ignore_ascii_case(c)))); + let k = match (step, at) { + (Step::First, _) | (Step::Next, None) => 0, + (Step::Last, _) | (Step::Previous, None) => n - 1, + (Step::Next, Some(k)) => (k + 1) % n, + (Step::Previous, Some(k)) => (k + n - 1) % n, + }; + let name = order.get(k).and_then(|&i| s.tools.presets.get(i)).map(|p| p.name.clone()).ok_or_else(|| EngineError::Other("no such brush preset".into()))?; + crate::brush_cmds::set_brush(s, &json!({ "preset": name }))?; + Ok(json!({ "preset": name })) +} + fn hardness(s: &mut Session, up: bool) -> Result { let hardness = hardness_step(s.tools.brush.hardness, up); crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } })) @@ -68,6 +147,10 @@ pub fn specs() -> Vec { key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)), key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)), key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)), + key_cmd!("tools.previousBrush", "Previous Brush", ",", |s, _| step_preset(s, Step::Previous)), + key_cmd!("tools.nextBrush", "Next Brush", ".", |s, _| step_preset(s, Step::Next)), + key_cmd!("tools.firstBrush", "First Brush", "Shift+,", |s, _| step_preset(s, Step::First)), + key_cmd!("tools.lastBrush", "Last Brush", "Shift+.", |s, _| step_preset(s, Step::Last)), CommandSpec { id: "tools.rotateBrush", label: "Rotate Brush Tip", @@ -120,6 +203,78 @@ mod tests { assert_eq!(s.tools.brush.angle, -15.0); } + fn preset(name: &str, group: &str, folder: &[&str], size: f32) -> photocraft_paint::BrushPreset { + photocraft_paint::BrushPreset { + name: name.into(), + brush: photocraft_paint::BrushSettings { size, ..Default::default() }, + builtin: false, + group: group.into(), + folder: folder.iter().map(|f| f.to_string()).collect(), + captures: Default::default(), + } + } + + #[test] + fn panel_order_groups_then_folders_depth_first() { + let presets = vec![ + preset("a", "G1", &[], 1.0), + preset("b", "G2", &[], 2.0), + preset("c", "G1", &["F"], 3.0), + preset("d", "G1", &[], 4.0), + preset("e", "G1", &["F"], 5.0), + preset("f", "G1", &["F", "Sub"], 6.0), + preset("g", "G1", &["F"], 7.0), + ]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + // G1: a, folder F (c, e, then Sub's f where its first preset is, g), d; then G2: b. + assert_eq!(names, ["a", "c", "e", "f", "g", "d", "b"]); + // No group is the panel's "My Brushes": one group with presets named into it. + let presets = vec![preset("x", "", &[], 1.0), preset("y", UNGROUPED, &[], 1.0), preset("z", "", &[], 1.0), preset("w", "G", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["x", "y", "z", "w"]); + // A name only a case apart from an earlier one can't be picked by name: skipped. + let presets = vec![preset("Foo", "", &[], 1.0), preset("foo", "", &[], 1.0), preset("Bar", "", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["Foo", "Bar"]); + // A skipped duplicate still places its group where the panel does: G2 (from `foo`) comes + // before G3, so the panel's Foo, foo, Baz, Bar steps as Foo, Baz, Bar. + let presets = vec![preset("Foo", "G1", &[], 1.0), preset("foo", "G2", &[], 1.0), preset("Bar", "G3", &[], 1.0), preset("Baz", "G2", &[], 1.0)]; + let names: Vec<&str> = panel_order(&presets).into_iter().map(|i| presets[i].name.as_str()).collect(); + assert_eq!(names, ["Foo", "Baz", "Bar"]); + } + + #[test] + fn previous_and_next_brush_walk_the_presets_and_wrap() { + let mut s = Session::new(); + s.tools.presets = vec![preset("One", "", &[], 11.0), preset("Two", "", &[], 22.0), preset("Three", "", &[], 33.0)]; + s.tools.current_preset = None; + let picked = |s: &Session| (s.tools.current_preset.clone().unwrap_or_default(), s.tools.brush.size); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("One".into(), 11.0), "Next with no preset picked starts at the first"); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("Two".into(), 22.0)); + s.execute("tools.lastBrush", Value::Null).unwrap(); + assert_eq!(picked(&s), ("Three".into(), 33.0)); + s.execute("tools.nextBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "One", "wraps past the last"); + s.execute("tools.previousBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "Three", "wraps before the first"); + s.execute("tools.firstBrush", Value::Null).unwrap(); + assert_eq!(picked(&s).0, "One"); + let sc = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!( + [sc("tools.previousBrush"), sc("tools.nextBrush"), sc("tools.firstBrush"), sc("tools.lastBrush")], + [Some(","), Some("."), Some("Shift+,"), Some("Shift+.")] + ); + // No presets: an error, and the brush and the current preset stay as they were. + s.tools.presets.clear(); + let (brush, current) = (s.tools.brush.clone(), s.tools.current_preset.clone()); + for id in ["tools.previousBrush", "tools.nextBrush", "tools.firstBrush", "tools.lastBrush"] { + assert!(s.execute(id, Value::Null).is_err(), "{id}"); + } + assert_eq!((s.tools.brush.clone(), s.tools.current_preset.clone()), (brush, current)); + } + #[test] fn commands_step_the_session_brush_and_journal() { let mut s = Session::new(); diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 46331126ac..1c126ec477 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -109,21 +109,29 @@ fn rename(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.rename"; let i = find(s, p, "name", cmd)?; let new = text(p, "newName", cmd)?; - if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) { + rename_at(s, i, &new, cmd)?; + Ok(json!({ "name": new })) +} + +/// Rename preset `i` (shared by Brush Settings and Edit › Presets › Preset Manager): refuses a +/// name another preset has (case-insensitively), makes the preset the user's and keeps the +/// current preset pointing at it. +pub(crate) fn rename_at(s: &mut Session, i: usize, new: &str, cmd: &str) -> Result<()> { + if index_of(&s.tools.presets, new).is_some_and(|j| j != i) { return Err(bad(cmd, format!("a brush preset named `{new}` already exists"))); } let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?; if pr.name != new { let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name)); - pr.name = new.clone(); + pr.name = new.to_string(); pr.builtin = false; if was_current { - s.tools.current_preset = Some(new.clone()); + s.tools.current_preset = Some(new.to_string()); s.tools.brush_changed(); } s.brush_presets_changed(); } - Ok(json!({ "name": new })) + Ok(()) } fn move_preset(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0722c6bd10..3233135132 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -55,9 +55,15 @@ fn map_fill(a: &Affine, f: &mut Fill) { if let Fill::Gradient { angle, offset, .. } = f { *angle = map_angle(a, *angle); // The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns - // and flips, where the frame's sides swap with the axes. + // and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the + // frame too, so only each axis's direction applies. + let unit = |x: f64, y: f64| { + let n = x.hypot(y); + if n < EPS { (x, y) } else { (x / n, y / n) } + }; + let ((m0, m1), (m2, m3)) = (unit(a.m[0], a.m[1]), unit(a.m[2], a.m[3])); let (ox, oy) = (f64::from(offset.0), f64::from(offset.1)); - *offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32); + *offset = ((m0 * ox + m2 * oy) as f32, (m1 * ox + m3 * oy) as f32); } } @@ -71,6 +77,40 @@ fn retag_text(dpi: f32, t: &mut TextLayer) { t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } +/// Sets the document resolution keeping every type layer's size in pixels, as Photoshop does (its +/// point size changes instead). Text space is in pixels at `pt x dpi / 72`, so it grows by +/// new / old: box and path scale with it, and the layer transform undoes that. +pub(crate) fn set_resolution(doc: &mut Document, dpi: f32) { + let old = std::mem::replace(&mut doc.resolution_dpi, dpi); + if !(old.is_finite() && old > 0.0 && dpi.is_finite() && dpi > 0.0) || old == dpi { + return; + } + let k = f64::from(dpi) / f64::from(old); + let grow = Affine::scale(k); + crate::image_cmds::for_each_layer(&mut doc.layers, &mut |l| { + let LayerContent::Text(t) = &mut l.content else { return }; + t.transform = t.transform.mul(&Affine::scale(1.0 / k)); + match &mut t.shape { + photocraft_doc::text::TextShape::Box { x, y, width, height } => { + for v in [x, y, width, height] { + *v = grown(*v, k); + } + } + photocraft_doc::text::TextShape::Path { path, start, .. } => { + path.knots.iter_mut().for_each(|kn| *kn = kn.transform(&grow)); + *start = grown(*start, k); + } + photocraft_doc::text::TextShape::Point => {} + } + retag_text(dpi, t); + }); +} + +/// `v x k` kept finite (a huge box from a file must not become infinite). +fn grown(v: f32, k: f64) -> f32 { + (f64::from(v) * k).clamp(-f64::from(f32::MAX), f64::from(f32::MAX)) as f32 +} + /// Applies `a` to one layer's (and its children's) non-pixel geometry. With `content` false only /// the parts Free Transform (`transform_layer`) doesn't move are touched: unlinked vector masks, /// gradient fill angles, effect reference points and artboards. @@ -150,15 +190,24 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if m1.abs() < EPS && m2.abs() < EPS { g.horizontal.iter_mut().for_each(|y| *y = m3 * *y + ty); g.vertical.iter_mut().for_each(|x| *x = m0 * *x + tx); + // Colours are keyed by exact position, so they move by the same expressions. + for c in &mut g.colors { + c.position = if c.vertical { m0 * c.position + tx } else { m3 * c.position + ty }; + } } else if m0.abs() < EPS && m3.abs() < EPS { // A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa. let v: Vec = g.horizontal.iter().map(|y| m2 * y + tx).collect(); let h: Vec = g.vertical.iter().map(|x| m1 * x + ty).collect(); g.horizontal = h; g.vertical = v; + for c in &mut g.colors { + c.position = if c.vertical { m1 * c.position + ty } else { m2 * c.position + tx }; + c.vertical = !c.vertical; + } } else { g.horizontal.clear(); g.vertical.clear(); + g.colors.clear(); } } @@ -238,7 +287,8 @@ impl Turn { } /// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object, -/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection. +/// fill), stored video frames (current and original), masks (layer, smart filter), alpha +/// channels, Quick Mask and the selection. fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { let re = |s: &mut Surface| *s = remap_surface(s, map); @@ -249,6 +299,10 @@ fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { if let Some(fc) = &mut l.fill_cache { re(&mut fc.surface); } + // Frames away from the playhead come back on scrubbing; they turn with the canvas (#4956). + if let Some(v) = &mut l.video { + v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()).for_each(re); + } match &mut l.content { LayerContent::Raster(s) => re(s), LayerContent::Text(t) => t.cache.iter_mut().for_each(re), diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 804a18b665..2ba0dfbd66 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -188,6 +188,39 @@ fn gradient_fill_angle_follows_the_canvas() { assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0); } +/// The gradient fill's centre offset after `cmd`, as `gradient.fill.get` reports it (% of the frame). +fn fill_offset_after(cmd: &str, p: serde_json::Value) -> (f64, f64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("gradient.fill.create", json!({"from": [10, 50], "to": [90, 50], "style": "radial"})).unwrap(); + s.execute("gradient.fill.set", json!({"angle": 0, "offset": [25, 0]})).unwrap(); + s.execute(cmd, p).unwrap(); + let g = s.execute("gradient.fill.get", json!({})).unwrap(); + (g["offset"][0].as_f64().unwrap(), g["offset"][1].as_f64().unwrap()) +} + +#[test] +fn gradient_fill_offset_stays_a_fraction_of_the_frame() { + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + // Image Size scales the frame, so the offset (a fraction of it) stays put. + for (w, h) in [(200, 200), (50, 50), (200, 50)] { + let o = fill_offset_after("image.imageSize", json!({"width": w, "height": h})); + assert!(near(o, (25.0, 0.0)), "{w}x{h}: {o:?}"); + } + // Flips and 90° turns still mirror and turn it. + assert!(near(fill_offset_after("image.imageRotation.flipCanvasHorizontal", json!({})), (-25.0, 0.0))); + assert!(near(fill_offset_after("image.imageRotation.90cw", json!({})), (0.0, 25.0))); + assert!(near(fill_offset_after("image.imageRotation.180", json!({})), (-25.0, 0.0))); + // Non-uniform scale with a turn: only the axis swap applies. + let mut f = Fill::gradient(Vec::new(), 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); + if let Fill::Gradient { offset, .. } = &mut f { + *offset = (0.25, -0.1); + } + map_fill(&Affine { m: [0.0, 3.0, -0.5, 0.0, 7.0, 1.0] }, &mut f); + let Fill::Gradient { offset, .. } = f else { panic!("not a gradient") }; + assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}"); +} + fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) { let mut t = None; let mut k = None; @@ -230,6 +263,28 @@ fn canvas_size_and_crop_move_vector_geometry() { assert!(mean < 1.0 / 255.0, "{mean}"); } +#[test] +fn guide_colours_follow_their_guides() { + const MAGENTA: [u8; 3] = [255, 0, 255]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "#ff00ff"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + // Crop shifts both guides by 10: the colour goes with the guide at 20, not to the one now there. + s.execute("image.crop", json!({"x": 10, "y": 0, "width": W - 10, "height": H})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.vertical, vec![10.0, 20.0]); + assert_eq!((g.color(true, 10.0), g.color(true, 20.0)), (Some(MAGENTA), None)); + // A quarter turn makes it a horizontal guide at y = 10, still magenta. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.horizontal, vec![10.0, 20.0]); + assert_eq!((g.color(false, 10.0), g.color(true, 10.0)), (Some(MAGENTA), None)); + // An arbitrary angle drops the guides, so their colours go too. + s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap(); + assert!(doc(&s).guides.colors.is_empty()); +} + #[test] fn image_size_scales_vector_geometry() { let mut s = mixed(8, "rgb"); @@ -270,6 +325,30 @@ fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() { assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}"); } +/// A canvas turn moves every stored video frame, not just the displayed one (#4956). +#[test] +fn turns_move_stored_video_frames() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (x, y) == (1, 2) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute(command(Turn::FlipHorizontal), json!({})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(W as i32 - 2, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(frame.pixel(1, 2)[3], 0.0); + } +} + #[test] fn turns_on_empty_and_tiny_documents_never_panic() { for (w, h) in [(1, 1), (1, 7), (5, 1)] { diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 468db2698b..0790537026 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -701,7 +701,7 @@ pub fn follows_target(id: &str) -> bool { } /// Commands whose target follows the Channels panel. -fn routed(id: &str) -> bool { +pub(crate) fn routed(id: &str) -> bool { id.starts_with("filter.") || id == "plugin.run" || id.starts_with("image.adjustments.") @@ -1086,17 +1086,19 @@ fn indicates_param(v: Option<&Value>) -> Option { } } -fn color_value(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_value(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn new_channel(s: &mut Session, p: &Value) -> Result { let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string(); let name = p.get("name").and_then(Value::as_str).map(str::to_string); let indicates = indicates_param(p.get("indicates")); - let color = color_value(p, "color"); + let color = color_value("channel.new", p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let i = s.edit("New Channel", |doc, _| { let area = doc.bounds(); @@ -1125,7 +1127,7 @@ fn new_channel(s: &mut Session, p: &Value) -> Result { } fn new_spot(s: &mut Session, p: &Value) -> Result { - let color = color_value(p, "color").unwrap_or(Color::rgb(1.0, 0.0, 0.0)); + let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0)); let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); @@ -1421,10 +1423,11 @@ fn options(s: &mut Session, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?; let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - let color = color_value(p, "color"); + let color = color_value(cmd, p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string); + let display_only = name.is_none() && kind.is_none() && solidity.is_none(); let apply = move |ch: &mut AlphaChannel| -> Result<()> { if let Some(n) = &name { ch.name = n.clone(); @@ -1493,7 +1496,31 @@ fn options(s: &mut Session, p: &Value) -> Result { })?; } } - _ => return Err(bad(cmd, "`channel` must be an alpha/spot channel or \"quickMask\"")), + ChanRef::LayerMask => { + // Layer Mask Display Options: only the rubylith's colour and opacity, for every mask. + if !display_only { + return Err(bad(cmd, "a layer mask's options are only `color` and `opacity`")); + } + // A preference, kept across restarts; not an undo step. + if color.is_some() || opacity.is_some() { + s.edit_prefs(|prefs| { + let o = &mut prefs.layer_mask_overlay; + if let Some(c) = color { + o.color = crate::adjust_params::hex(c.to_rgb()); + } + if let Some(a) = opacity { + o.opacity = (a * 100.0).round(); + } + }); + } + let o = serde_json::to_value(&s.prefs().layer_mask_overlay).unwrap_or(Value::Null); + let mut out = list(s); + if let Some(m) = out.as_object_mut() { + m.insert("layerMaskOverlay".into(), o); + } + return Ok(out); + } + _ => return Err(bad(cmd, "`channel` must be an alpha/spot channel, \"quickMask\" or \"mask\"")), } Ok(list(s)) } @@ -1669,14 +1696,24 @@ fn apply_image(s: &mut Session, p: &Value) -> Result { let area = doc.bounds(); let selection = doc.selection.as_ref().map(|m| sel::mask_from_surface(Some(m), area)); 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]); - if is_channel_target(p) { + 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). 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 (surf, _) = target_surface(doc, None, p)?; + 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(); - *surf = plane_surface(&out, area, surf.format()); + if mask_target { + // A layer mask keeps its default (reveal or hide) outside the canvas. + if surf.channels() != 1 { + return Err(EngineError::Other("the layer mask is not a grayscale surface".into())); + } + surf.write_region(area, &out); + surf.prune(); + } else { + *surf = plane_surface(&out, area, surf.format()); + } return Ok(()); } let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; @@ -1890,7 +1927,7 @@ pub fn specs() -> Vec { "Channel Options…", [], None, - r##"{"channel":index|name|"quickMask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?}"##, + r##"{"channel":index|name|"quickMask"|"mask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?} ("mask": Layer Mask Display Options, the rubylith's color and opacity for every layer mask, kept in the preferences as layerMaskOverlay; not an undo step; returns them as layerMaskOverlay)"##, has_doc, options ), diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index d63dc5fd0d..2ae1bd94b0 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -401,6 +401,41 @@ fn quick_mask_without_selection_and_options() { assert!(sel_at(&s, 10, 10) < 0.05 && sel_at(&s, 30, 10) > 0.99, "{} {}", sel_at(&s, 10, 10), sel_at(&s, 30, 10)); } +/// Layer Mask Display Options: `channel.options` on `"mask"` sets the rubylith's colour and +/// opacity for every mask. They're a preference (kept across restarts, as in Photoshop), not an +/// undo step, and come back in the result. +#[test] +fn layer_mask_display_options() { + let mut s = session(); + let o = || crate::prefs::LayerMaskOverlay::default(); + assert_eq!(s.prefs().layer_mask_overlay, o()); + assert_eq!((o().rgb(), o().alpha()), ([1.0, 0.0, 0.0], 0.5)); + let hist = s.active().unwrap().history.entries().len(); + let r = s.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 30})).unwrap(); + assert_eq!(r["layerMaskOverlay"], json!({"color": "#00ff00", "opacity": 30.0})); + assert_eq!(s.execute("prefs.get", json!({"path": "layerMaskOverlay"})).unwrap(), json!({"color": "#00ff00", "opacity": 30.0})); + assert_eq!(s.active().unwrap().history.entries().len(), hist, "not an undo step"); + // Just one of them leaves the other; no fields reads them back; out of range clamps. + s.execute("channel.options", json!({"channel": "mask", "opacity": 250})).unwrap(); + let r = s.execute("channel.options", json!({"channel": "layerMask"})).unwrap(); + assert_eq!(r["layerMaskOverlay"], json!({"color": "#00ff00", "opacity": 100.0})); + // A layer mask has no name, Color Indicates or solidity here. + for bad in [json!({"name": "x"}), json!({"indicates": "selected"}), json!({"indicates": "spot"}), json!({"solidity": 20})] { + let mut p = bad.clone(); + p["channel"] = json!("mask"); + assert!(s.execute("channel.options", p).is_err(), "{bad}"); + } + assert_eq!(s.prefs().layer_mask_overlay.opacity, 100.0, "a refused call changes nothing"); + // prefs.set validates them like other preferences; prefs.reset restores red at 50 %. + assert!(s.execute("prefs.set", json!({"path": "layerMaskOverlay.color", "value": "green"})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "layerMaskOverlay.opacity", "value": 101})).is_err()); + s.execute("prefs.reset", json!({"path": "layerMaskOverlay"})).unwrap(); + assert_eq!(s.prefs().layer_mask_overlay, o()); + // A hand-edited settings file can't break the view. + let odd = crate::prefs::LayerMaskOverlay { color: "nope".into(), opacity: f32::NAN }; + assert_eq!((odd.rgb(), odd.alpha()), ([1.0, 0.0, 0.0], 0.5)); +} + /// Overlay coverage of the live Quick Mask at (x, y): how much colour the canvas shows there. fn qm_overlay(s: &Session, x: i32, y: i32) -> f32 { let q = doc(s).quick_mask.as_ref().unwrap(); @@ -592,6 +627,25 @@ fn apply_image_into_alpha_channel() { assert!(chan(&s, 0, 3, 3) < 0.01); } +#[test] +fn apply_image_into_targeted_layer_mask_leaves_the_pixels() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + // The green channel of the red composite is black: it lands in the mask, not the layer. + s.execute("image.applyImage", json!({"source": {"channel": "green"}, "blending": "normal"})).unwrap(); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let m = &l.mask.as_ref().unwrap().surface; + assert!(m.sample_channel(3, 3, 0) < 0.01, "mask {}", m.sample_channel(3, 3, 0)); + // Outside the canvas the mask keeps revealing. + assert!(m.sample_channel(-5, -5, 0) > 0.99, "mask outside {}", m.sample_channel(-5, -5, 0)); + let p = px(&s, 3, 3); + assert!(p[0] > 0.99 && p[1] < 0.01 && p[2] < 0.01 && p[3] > 0.99, "layer pixels {p:?}"); +} + #[test] fn calculations_results() { let mut s = session(); diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index d3a6d36931..4f3435cc7a 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -459,6 +459,21 @@ impl ColorState { self.canvas_lut_with(doc, size, false, display) } + /// Accurate GPU representation of RGB matrix/TRC display transforms. Gamut warnings, + /// proof simulations beyond profile conversion, and transforms containing a CLUT retain + /// the existing 3D LUT path. The shader applies the 32-bit preview before this transform, + /// just as it does before the display LUT. + pub fn gpu_canvas_matrix_shaper_for(&self, doc: &Document, display: Option) -> Result> { + let proof = self.proof(doc.id); + if proof.gamut_warning || (proof.enabled && proof.setup.kind != crate::proof_sim::ProofKind::Profile) { + return Ok(None); + } + if !proof.enabled && self.canvas_display_for(doc, display)?.transform.is_none() { + return Ok(None); + } + Ok(self.display_transform_for(doc, display)?.gpu_matrix_shaper()) + } + fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool, on: Option) -> Result>> { let size = size.max(2); let pv = self.proof(doc.id); @@ -1338,6 +1353,44 @@ mod tests { assert_eq!(sh.color.mode, ColorMode::Cmyk); } + #[test] + fn gpu_matrix_shaper_keeps_special_proof_simulations_on_the_lut_path() { + let mut s = session("rgb", 8); + let d = doc(&s).clone(); + // These valid synthetic RGB profiles make the ordinary profile-proof pipeline + // separable. Thus the test cannot pass just because a typical proof happens to + // contain unsupported stages; the simulation kind must be excluded explicitly. + let mut proof = Builtin::LinearSrgb.profile().clone(); + proof.description = "Synthetic identity-matrix proof".into(); + proof.matrix = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + proof.trc = Some([photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity]); + let proof = proof.with_encoded_bytes(); + let mut monitor = proof.clone(); + monitor.description = "Synthetic identity-matrix gamma monitor".into(); + monitor.trc = Some(std::array::from_fn(|_| photocraft_cms::Curve::Gamma(2.2))); + let monitor = monitor.with_encoded_bytes(); + s.color.set_displays(Ok(vec![crate::display_color::Display { + id: 1, + name: "Synthetic monitor".into(), + frame: [0.0, 0.0, 800.0, 600.0], + profile_name: None, + icc: Some(monitor.to_bytes()), + }])); + let pv = s.color.proof_mut(d.id); + pv.setup.profile = Arc::new(Profile::parse(&proof.to_bytes()).unwrap()); + pv.enabled = true; + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "ordinary profile conversion is separable"); + for command in ["view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] { + // These commands retain the previously selected custom profile. + s.execute(command, json!({})).unwrap(); + assert!(s.color.display_transform_for(&d, Some(1)).unwrap().gpu_matrix_shaper().is_some(), "fixture must exercise the shortcut"); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_none(), "{command} must retain its simulation"); + assert!(s.color.gpu_canvas_lut_for(&d, 9, Some(1)).unwrap().is_some(), "{command} still has its LUT fallback"); + } + s.execute("view.proofColors", json!({"on": false})).unwrap(); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "disabled simulations do not block ordinary display conversion"); + } + #[test] fn proofing_state_and_gamut_warning() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index 34356a3bfb..ba8b1aebcc 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -34,7 +34,7 @@ fn unit(v: f32, default: f32) -> f32 { /// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to /// the defaults). [`run`] validates strictly before it gets here. pub(crate) fn params(p: &Value) -> FilterParams { - let c = crate::commands::color_param(p, "color", WHITE); + let c = crate::commands::color_param(ID, p, "color", WHITE).unwrap_or(WHITE); let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d)); FilterParams::ColorToAlpha { color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ced8462c9e..2b65788fcc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -117,7 +117,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> { fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result { if let Some(d) = s.active() { let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height)); - s.set_sampler_map(a); + // Sampler points are pixel indices, not pixel edges: map the pixel's centre, so the point + // lands on the same pixel `Turn::pixel_map` moves the pixels to (`w - 1 - x`). + let half = photocraft_geom::Affine::translate(0.5, 0.5); + s.set_canvas_map(a, half.then(&a).then(&photocraft_geom::Affine::translate(-0.5, -0.5))); } s.edit(label, |doc, _| { crate::canvas_geom::turn_canvas(doc, t); @@ -229,18 +232,53 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result f32 { - p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) +/// JSON numbers may be finite f64 values but overflow when stored in a document's f32 field. +pub(crate) fn finite_f32(cmd: &str, key: &str, value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { Ok(narrowed) } else { Err(bad(cmd, format!("`{key}` must fit a finite 32-bit number"))) } } -pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { - match p.get(key) { + +/// Effect/type parameters contain nested colour arrays and snapshots as well as scalars. +/// Validate before parsing or editing, including numbers hidden in an effect snapshot. +pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { + // Bound the call stack, without allocating a work list proportional to an input array. + fn check(cmd: &str, value: &Value, depth: usize) -> Result<()> { + if depth > 64 { + return Err(bad(cmd, "parameters nested deeper than 64 levels")); + } + match value { + Value::Number(n) => { + finite_f32(cmd, "number", n.as_f64().ok_or_else(|| bad(cmd, "invalid number"))?)?; + } + Value::Array(a) => { + for v in a { + check(cmd, v, depth + 1)?; + } + } + Value::Object(o) => { + for v in o.values() { + check(cmd, v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } + check(cmd, value, 0) +} + +pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { + Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)] + let mut out = [0.0, 0.0, 0.0, 1.0]; + for (slot, value) in out.iter_mut().zip(a) { + *slot = finite_f32(cmd, key, value.as_f64().unwrap_or(0.0))?; + } + out } Some(Value::String(s)) => parse_hex(s).unwrap_or(default), _ => default, - } + }) } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); @@ -362,7 +400,7 @@ pub(crate) fn selection_mask(doc: &Document) -> Result> { /// New pixel masks use the document's depth, as PSD saves/imports do. Otherwise a later soft /// brush stroke quantizes differently depending on whether the document reopened as PSD or /// native. Converting a selection keeps its coverage and does not alter the selection itself. -fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { +pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { let format = mask.surface.format().with_sample(depth); if mask.surface.format() != format { mask.surface = mask.surface.try_convert(format)?; @@ -370,12 +408,18 @@ fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result { Ok(mask) } +/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands +/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults. +pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool { + s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback) +} + /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the /// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its /// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). /// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> { +pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -386,7 +430,9 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?), + None => { + l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; + } } Ok(()) } @@ -395,12 +441,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str pub(crate) const NEW_LAYER_PATH: &str = r##""path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask, or white with no selection)"##; +/// 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). fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result { let label = format!("New {} Layer", adj.label()); let name = adj.label().to_string(); 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)?; + mask_new_layer(doc, &mut l, p, cmd, true)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -408,6 +455,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res Ok(json!({ "layer": id.0 })) } +/// Apply `adj` in place to the targeted layer mask, the targeted alpha channel or Quick Mask, or else the active layer's pixels, as one history step named `label`. fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) -> Result { if is_mask_target(p) { // The targeted layer mask (#780): ⌘I inverts it, as in Photoshop. @@ -490,7 +538,7 @@ fn build() -> Vec { "New…", ["File"], Some("Cmd+N"), - r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str}"##, + r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##, always, |s, p| { use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type}; @@ -526,6 +574,18 @@ fn build() -> Vec { } }; doc.resolution_dpi = res; + // Photoshop's New Document › Color Profile: tag the document with another profile + // than the working space (#321). Checked before the document opens. + if let Some(spec) = p.get("profile").and_then(Value::as_str).filter(|spec| !matches!(*spec, "working" | "none")) { + let prof = s.color.resolve(spec, Some(&doc), None)?; + if prof.color_space != crate::color_cmds::mode_space(doc.mode) { + return Err(EngineError::BadParams { + cmd: "file.new".into(), + msg: format!("profile `{}` is {:?}; the document is {:?}", prof.description, prof.color_space, doc.mode), + }); + } + doc.icc_profile = Some(prof.to_bytes()); + } let i = s.add_document(doc, None); Ok(json!({ "document": i })) } @@ -543,7 +603,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), @@ -578,6 +638,10 @@ fn build() -> Vec { Ok(Value::Null) }), cmd!("select.deselect", "Deselect", ["Select"], Some("Cmd+D"), "{}", has_selection, |s, _| { + // Nothing selected: nothing to do and no history step, as in Photoshop (#6744). + if s.active().is_none_or(|d| d.doc.selection.is_none()) { + return Ok(Value::Null); + } s.edit("Deselect", |doc, _| { doc.selection = None; Ok(()) @@ -713,9 +777,9 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let nid = s.edit("Duplicate Layer", |doc, active| { - let mut dup = layer_copy(doc, id)?; + let mut dup = layer_copy(doc, id, add_copy)?; if let Some(name) = &name { dup.name.clone_from(name); } @@ -726,7 +790,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), @@ -991,11 +1054,12 @@ fn build() -> Vec { r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { - let c = color_param(p, "color", s.tools.foreground); + let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); - mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -1011,11 +1075,12 @@ fn build() -> Vec { r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, has_doc, |s, p| { - let a = color_param(p, "from", s.tools.foreground); - let b = color_param(p, "to", s.tools.background); - let angle = f32_or(p, "angle", 90.0); + let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; + let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?; + let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?; let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Gradient Fill Layer", |doc, active| { let fill = Fill::gradient( vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], @@ -1026,7 +1091,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")?; + mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) @@ -1079,7 +1144,7 @@ fn build() -> Vec { "Move Layer", [], None, - r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##, + r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels","selection":bool=false} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer; selection: the selection moves too, in the same step)"##, has_layer, crate::layer_multi_cmds::translate ), @@ -1143,8 +1208,10 @@ fn build() -> Vec { } }; let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); - s.tools.foreground = color_param(p, "foreground", s.tools.foreground); - s.tools.background = color_param(p, "background", s.tools.background); + let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; + let background = color_param("tools.setColors", p, "background", s.tools.background)?; + s.tools.foreground = foreground; + s.tools.background = background; if let Some(k) = fg_cmyk { let [r, g, b, _] = s.tools.foreground; s.tools.remember_cmyk([r, g, b], k); @@ -1205,12 +1272,33 @@ fn build() -> Vec { label: "Activate Document", menu: &[], shortcut: None, - params: r##"{"document":index}"##, + params: r##"{"document":index} or {"offset":int} (the open document that many tabs after the active one, before it when negative: a Photoshop action's switch to another document) → {document}"##, enabled: has_doc, run: |s, p| { - let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; - let idx = usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))?; - if s.set_active(idx) { Ok(Value::Null) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } + // A null key counts as omitted. + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let idx = match (given("document"), given("offset")) { + (Some(_), Some(_)) => return Err(bad("document.activate", "give `document` or `offset`, not both")), + (None, None) => return Err(bad("document.activate", "needs `document` (a tab index) or `offset`")), + // Past the first or last tab is refused rather than wrapped, so an action that + // copies from one document never silently pastes into the wrong one (#7103). + (None, Some(o)) => { + let o = o.as_i64().ok_or_else(|| bad("document.activate", "`offset` must be an integer"))?; + let (active, len) = (s.active_index().ok_or(EngineError::NoDocument)?, s.documents().len()); + let target = i64::try_from(active).ok().and_then(|a| a.checked_add(o)).and_then(|i| usize::try_from(i).ok()); + let Some(i) = target.filter(|&i| i < len) else { + let side = if o < 0 { "before" } else { "after" }; + let msg = format!("no document {} {side} the active one, which is {} of {len}", o.unsigned_abs(), active + 1); + return Err(EngineError::Other(msg)); + }; + i + } + (Some(d), None) => { + let i = d.as_u64().ok_or_else(|| bad("document.activate", "`document` must be a tab index"))?; + usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))? + } + }; + if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::Other(format!("no document at index {idx}"))) } }, journal: false, }, @@ -1396,6 +1484,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::contour_lib_cmds::specs()); v.extend(crate::layer_list_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); @@ -1457,6 +1546,7 @@ fn build() -> Vec { v.extend(crate::adjust_cmds::specs()); v.extend(crate::layer_menu_cmds::specs()); v.extend(crate::layer_label_cmds::specs()); + v.extend(crate::layer_xmp_cmds::specs()); v.extend(crate::mode_cmds::specs()); v.extend(crate::multichannel_cmds::specs()); v.extend(crate::pattern_cmds::specs()); @@ -1467,6 +1557,8 @@ fn build() -> Vec { v.extend(crate::analysis_cmds::specs()); v.extend(crate::notes_cmds::specs()); v.extend(crate::history_cmds::specs()); + v.extend(crate::history_menu_cmds::specs()); + v.extend(crate::history_source_cmds::specs()); v.extend(crate::history_select_cmds::specs()); v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); @@ -1543,6 +1635,7 @@ pub fn adjustment_kind(a: &Adjustment) -> &'static str { Adjustment::GradientMap { .. } => "gradientMap", Adjustment::SelectiveColor { .. } => "selectiveColor", Adjustment::ColorLookup { .. } => "colorLookup", + Adjustment::Unsupported { .. } => "unsupported", _ => "invert", } } @@ -1658,12 +1751,14 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { /// A copy of layer `id` as Layer › Duplicate Layer makes it, not yet in the document: a fresh id, /// "… copy" appended to the name, and a copy of the Background layer is an ordinary, unlocked /// layer (Photoshop). -pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result { +pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result { let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?; // A copy of the Background layer is an ordinary, unlocked layer (Photoshop). let from_background = doc.is_background(id); let mut dup = src.duplicate(); - dup.name = doc.copy_name(&dup.name); + if add_copy { + dup.name = doc.copy_name(&dup.name); + } if from_background { dup.locks = Default::default(); } diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index fe5708387c..db239c328f 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -208,6 +208,23 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { apply(s, id, "Apply Layer Comp") } +/// How an action records Apply Layer Comp: by the comp's name, as Photoshop does. Comp ids are +/// local to a document, so a recorded id would apply whichever comp of another document has it +/// (#7181); by name the step finds that document's comp of the name, or stops with an error. +/// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. +pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + let Some(d) = s.active() else { return p }; + let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; + // `null` params run as `{}` (the last applied comp), so they are recorded by name too. + if p.is_null() { + p = json!({}); + } + if let Value::Object(m) = &mut p { + m.insert("comp".into(), json!(comp.name)); + } + p +} + fn step(s: &mut Session, delta: i64) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let n = d.doc.layer_comps.len() as i64; @@ -234,6 +251,27 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": id})) } +fn reorder(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layerComp.reorder"; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let id = comp_param(doc, p, CMD)?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|i| *i < doc.layer_comps.len()) + .ok_or_else(|| bad(CMD, "index must name a position in the comp list"))?; + let from = index_of(doc, id); + if from != index { + s.edit("Reorder Layer Comp", |doc, _| { + let comp = doc.layer_comps.remove(from); + doc.layer_comps.insert(index, comp); + Ok(()) + })?; + } + Ok(json!({"comp": id, "index": index})) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let id = comp_param(&d.doc, p, "layerComp.duplicate")?; @@ -427,6 +465,7 @@ pub fn specs() -> Vec { spec!("layerComp.previous", "Apply Previous Layer Comp", &[], "{}", has_comps, |s, _| step(s, -1)), spec!("layerComp.next", "Apply Next Layer Comp", &[], "{}", has_comps, |s, _| step(s, 1)), spec!("layerComp.delete", "Delete Layer Comp", &[], r##"{"comp":id|name?}"##, has_comps, delete), + spec!("layerComp.reorder", "Reorder Layer Comp", &[], r##"{"comp":id|name?,"index":integer (zero-based final position)}"##, has_comps, reorder), spec!("layerComp.duplicate", "Duplicate Layer Comp", &[], r##"{"comp":id|name?} → {comp}"##, has_comps, duplicate), spec!("layerComp.rename", "Rename Layer Comp", &[], r##"{"comp":id|name?,"name":str}"##, has_comps, rename), spec!("layerComp.setComment", "Layer Comp Comment", &[], r##"{"comp":id|name?,"comment":str}"##, has_comps, set_comment), diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 5458523bcf..1762bce0a2 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -347,3 +347,96 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { assert_eq!(doc(&s).layer_comps, before); assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); } + +#[test] +fn reorder_comps_preserves_capture_and_applied_state_and_undoes() { + let (mut s, _, _) = session(8); + for name in ["One", "Two", "Three"] { + s.execute("layerComp.new", json!({"name":name})).unwrap(); + } + let before = doc(&s).layer_comps.clone(); + let applied = doc(&s).last_applied_comp; + let steps = s.active().unwrap().history.past_len(); + s.execute("layerComp.reorder", json!({"comp":"One", "index":2})).unwrap(); + assert_eq!(doc(&s).layer_comps, vec![before[1].clone(), before[2].clone(), before[0].clone()]); + assert_eq!(doc(&s).last_applied_comp, applied); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + assert!(s.undo()); + assert_eq!(doc(&s).layer_comps, before); + assert!(s.redo()); + s.execute("layerComp.reorder", json!({"comp":before[0].id, "index":0})).unwrap(); + assert_eq!(doc(&s).layer_comps, before); + let steps = s.active().unwrap().history.past_len(); + s.execute("layerComp.reorder", json!({"comp":"One", "index":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps); +} + +#[test] +fn reorder_comps_rejects_bad_indices_without_changes() { + let mut s = Session::new(); + assert!(!s.is_enabled("layerComp.reorder")); + s.execute("file.new", json!({"width":8, "height":8})).unwrap(); + assert!(!s.is_enabled("layerComp.reorder")); + s.execute("layerComp.new", json!({"name":"Only"})).unwrap(); + let before = doc(&s).layer_comps.clone(); + let steps = s.active().unwrap().history.past_len(); + for index in [Value::Null, json!(-1), json!(1), json!(1.5), json!("0"), json!(u64::MAX)] { + assert!(s.execute("layerComp.reorder", json!({"index":index})).is_err()); + } + assert!(s.execute("layerComp.reorder", json!({"comp":"Missing", "index":0})).is_err()); + assert_eq!(doc(&s).layer_comps, before); + assert_eq!(s.active().unwrap().history.past_len(), steps); +} + +/// Apply Layer Comp is recorded by the comp's name, not its document-local id: played in another +/// document it applies that document's comp of the name, or stops when there is none (#7181). +#[test] +fn a_recorded_apply_layer_comp_finds_the_comp_by_name() { + let (mut s, _, _) = session(8); + s.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + let wanted = doc(&s).layer_comps[0].id; + s.execute("actions.record", json!({"name": "ApplyWanted"})).unwrap(); + s.execute("layerComp.apply", json!({"comp": wanted})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + // `{}` and `null` apply the last applied comp; they are recorded by name too. + s.execute("actions.record", json!({"name": "ApplyLast"})).unwrap(); + s.execute("layerComp.apply", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.record", json!({"name": "ApplyNull"})).unwrap(); + s.execute("layerComp.apply", Value::Null).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + for action in ["ApplyWanted", "ApplyLast", "ApplyNull"] { + let steps = s.execute("actions.get", json!({"action": action})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["layerComp.apply", {"comp": "Wanted"}]]), "{action}"); + } + + // A second document whose comp with the recorded id is another one. + let (mut other, _, ob) = session(8); + other.actions = s.actions.clone(); + let show = |s: &mut Session, visible: bool| { + s.edit("t", |d, _| { + d.layer_mut(ob).unwrap().visible = visible; + Ok(()) + }) + .unwrap(); + }; + show(&mut other, false); + other.execute("layerComp.new", json!({"name": "Different"})).unwrap(); + show(&mut other, true); + other.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + assert_eq!(doc(&other).layer_comps[0].id, wanted, "the recorded id names `Different` here"); + show(&mut other, false); + other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + let applied = doc(&other).last_applied_comp.and_then(|id| doc(&other).comp(id)).map(|c| c.name.clone()); + assert_eq!(applied.as_deref(), Some("Wanted")); + assert!(doc(&other).layer(ob).unwrap().visible, "Wanted shows the layer"); + + // A document without a comp of that name stops the action instead of applying another one. + other.execute("layerComp.delete", json!({"comp": "Wanted"})).unwrap(); + show(&mut other, false); + let before = (doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible); + let played = other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("no layer comp named \"Wanted\""), "{played}"); + assert_eq!((doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible), before, "the document is unchanged"); +} diff --git a/crates/engine/src/contour_lib_cmds.rs b/crates/engine/src/contour_lib_cmds.rs new file mode 100644 index 0000000000..370c4da5cf --- /dev/null +++ b/crates/engine/src/contour_lib_cmds.rs @@ -0,0 +1,316 @@ +//! The user contour library of the Layer Style dialog (`layer.layerStyle.contours.*`). +//! +//! Photoshop's Contour Editor "New…" saves the edited curve under a name, and the contour picker +//! lists those contours after its standard ones (picker menu: Load, Save and Replace Contours). +//! The library is [`PresetState::contours`](crate::presets::PresetState); like the other preset +//! sets it lives in the preferences document and changes the preferences, not a document's +//! history. A user contour is applied to an effect as a `contour` curve object (see +//! [`contour_param`]); `.shc` files go through `photocraft_psd::shc` (see [`contours_from_shc`]). + +use photocraft_doc::Contour; +use photocraft_doc::adjust::CurvePoint; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::layer_style::{builtin_contour, contour_from_param, contour_param, contour_to_shc_all, contours_from_shc}; +use crate::presets::{always, bad, str_param}; +use crate::{Result, Session}; + +/// Most user contours kept (further ones are refused / dropped on load). +pub const MAX_USER_CONTOURS: usize = 1000; +/// Longest contour name kept, in characters. +pub const MAX_NAME: usize = 255; + +fn name_of(c: &Contour) -> &str { + match c { + Contour::Linear => "Linear", + Contour::Custom { name, .. } => name, + } +} + +fn clip(s: &str) -> String { + s.chars().take(MAX_NAME).collect() +} + +/// `c` as a named custom contour (Linear becomes the identity curve), so it can be stored. +fn named(c: Contour, name: &str) -> Contour { + match c { + Contour::Linear => Contour::Custom { + name: name.to_string(), + points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], + corners: Vec::new(), + }, + Contour::Custom { points, corners, .. } => Contour::Custom { name: name.to_string(), points, corners }, + } +} + +/// `base`, or `base 2`, `base 3`… so a name is neither a standard contour's nor a user contour's +/// (a name alone selects a standard contour, so a user contour may not shadow one). +fn unique(lib: &[Contour], base: &str) -> String { + let taken = |n: &str| builtin_contour(n).is_some() || lib.iter().any(|c| name_of(c) == n); + if !taken(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +/// Adds `c` under `name` (made unique); the stored name. +fn add(lib: &mut Vec, c: Contour, name: &str) -> String { + let n = unique(lib, &clip(name)); + lib.push(named(c, &n)); + n +} + +/// The library as saved with the preferences. +pub fn to_json(lib: &[Contour]) -> Value { + Value::Array(lib.iter().map(contour_param).collect()) +} + +/// Restores a saved library, skipping entries that aren't usable contours; never refuses. +pub fn from_json(v: &Value) -> Vec { + let mut lib = Vec::new(); + for e in v.as_array().into_iter().flatten().take(MAX_USER_CONTOURS) { + // Entries are curve objects; a bare name would only name a standard contour. + if let Some(c) = e.is_object().then(|| contour_from_param(e)).flatten() { + add(&mut lib, c.clone(), name_of(&c)); + } + } + lib +} + +fn list(s: &mut Session, _: &Value) -> Result { + let lib = &s.presets.contours; + Ok(json!({"count": lib.len(), "contours": lib.iter().map(|c| json!({"name": name_of(c), "contour": contour_param(c)})).collect::>()})) +} + +fn new(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.new"; + let c = contour_from_param(p.get("contour").ok_or_else(|| bad(CMD, "missing `contour` (a standard contour's name or a curve object)"))?) + .ok_or_else(|| bad(CMD, "`contour` is not a usable contour (unknown name, or a curve with fewer than 2 or more than 64 points)"))?; + if s.presets.contours.len() >= MAX_USER_CONTOURS { + return Err(bad(CMD, format!("the contour library is full ({MAX_USER_CONTOURS} contours)"))); + } + let name = match p.get("name") { + None | Some(Value::Null) => format!("Contour {}", s.presets.contours.len() + 1), + Some(Value::String(n)) if !n.trim().is_empty() => n.trim().to_string(), + Some(_) => return Err(bad(CMD, "`name` must be a non-empty string")), + }; + let name = add(&mut s.presets.contours, c, &name); + s.presets_changed(); + let contour = s.presets.contours.last().map(contour_param).unwrap_or(Value::Null); + Ok(json!({"name": name, "contour": contour, "count": s.presets.contours.len()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing `name`"))?; + let at = s + .presets + .contours + .iter() + .position(|c| name_of(c) == name) + .ok_or_else(|| bad(CMD, format!("no user contour \"{name}\" (see layer.layerStyle.contours.list)")))?; + s.presets.contours.remove(at); + s.presets_changed(); + Ok(json!({"deleted": name, "count": s.presets.contours.len()})) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.load"; + let replace = match str_param(p, "mode") { + None | Some("append") => false, + Some("replace") => true, + Some(m) => return Err(bad(CMD, format!("unknown mode `{m}` (append|replace)"))), + }; + let (bytes, _) = crate::preset_import_cmds::file_bytes(p, CMD, ".shc file")?; + let found = contours_from_shc(&bytes)?; + let loaded: Vec = found.iter().filter_map(contour_from_param).collect(); + if loaded.is_empty() { + return Err(bad(CMD, "the file holds no usable contours")); + } + let mut lib = if replace { Vec::new() } else { s.presets.contours.clone() }; + let mut names = Vec::new(); + for c in loaded { + if lib.len() >= MAX_USER_CONTOURS { + break; + } + let n = name_of(&c).to_string(); + names.push(add(&mut lib, c, &n)); + } + s.presets.contours = lib; + s.presets_changed(); + Ok(json!({"loaded": names, "count": s.presets.contours.len(), "skipped": found.len() - names.len()})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.contours.save"; + let lib: Vec<&Contour> = match p.get("names") { + None | Some(Value::Null) => s.presets.contours.iter().collect(), + Some(Value::Array(a)) => { + let mut v = Vec::new(); + for n in a { + let n = n.as_str().ok_or_else(|| bad(CMD, "`names` must be strings"))?; + v.push(s.presets.contours.iter().find(|c| name_of(c) == n).ok_or_else(|| bad(CMD, format!("no user contour \"{n}\"")))?); + } + v + } + Some(_) => return Err(bad(CMD, "`names` must be a list of contour names")), + }; + if lib.is_empty() { + return Err(bad(CMD, "there are no user contours to save")); + } + let bytes = contour_to_shc_all(&lib)?; + let mut out = json!({"count": lib.len(), "bytes": bytes.len()}); + match str_param(p, "path") { + Some(path) => { + crate::file_cmds::write_file(path, &bytes)?; + out["path"] = json!(path); + } + None => out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)), + } + Ok(out) +} + +/// Contour library command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "layer.layerStyle.contours.list", + label: "List User Contours", + menu: &[], + shortcut: None, + params: "{} → {count, contours:[{name, contour: curve object usable as any `contour` param}]}", + enabled: always, + run: list, + journal: false, + }, + CommandSpec { + id: "layer.layerStyle.contours.new", + label: "New Contour…", + menu: &[], + shortcut: None, + params: r##"{"contour":name|{"name":str,"points":[[in 0..1,out 0..1],…],"corners":[bool,…]},"name":str?="Contour N"} → {name (made unique, never a standard contour's name), contour, count} (Contour Editor › New…)"##, + enabled: always, + run: new, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.delete", + label: "Delete Contour", + menu: &[], + shortcut: None, + params: r##"{"name":str} → {deleted, count}"##, + enabled: always, + run: delete, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.load", + label: "Load Contours…", + menu: &[], + shortcut: None, + params: r##"{"path":".shc file"?,"data":base64 bytes?,"mode":"append"|"replace"="append"} → {loaded, count, skipped} (picker menu: Load Contours… / Replace Contours…)"##, + enabled: always, + run: load, + journal: true, + }, + CommandSpec { + id: "layer.layerStyle.contours.save", + label: "Save Contours…", + menu: &[], + shortcut: None, + params: r##"{"path":".shc file"?,"names":[str]?=all} → {count, bytes, path | data (base64 when no path)}"##, + enabled: always, + run: save, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn curve(name: &str) -> Value { + json!({"name": name, "points": [[0.0, 0.0], [0.3, 0.9], [1.0, 1.0]], "corners": [false, true, false]}) + } + + #[test] + fn new_save_load_round_trips_and_names_stay_unique() { + let mut s = Session::new(); + let r = new(&mut s, &json!({"contour": curve("x"), "name": "Ramp"})).unwrap(); + assert_eq!(r["name"], "Ramp"); + // A standard contour's name can't be shadowed; repeats get a suffix. + assert_eq!(new(&mut s, &json!({"contour": curve("x"), "name": "Cone"})).unwrap()["name"], "Cone 2"); + assert_eq!(new(&mut s, &json!({"contour": "Ring", "name": "Ramp"})).unwrap()["name"], "Ramp 2"); + let saved = save(&mut s, &json!({})).unwrap(); + let data = saved["data"].clone(); + assert_eq!(saved["count"], 3); + // Replace swaps the library for the file's contours; the curve and its corner survive. + delete(&mut s, &json!({"name": "Ramp"})).unwrap(); + let r = load(&mut s, &json!({"data": data, "mode": "replace"})).unwrap(); + assert_eq!(r["count"], 3); + let c = contour_from_param(&list(&mut s, &json!({})).unwrap()["contours"][0]["contour"]).unwrap(); + assert!(c.is_corner(1)); + // Appending the same file again keeps every name unique. + let r = load(&mut s, &json!({"data": data})).unwrap(); + assert_eq!(r["count"], 6); + let names: std::collections::HashSet<_> = s.presets.contours.iter().map(|c| name_of(c).to_string()).collect(); + assert_eq!(names.len(), 6); + } + + #[test] + fn library_persists_with_the_presets() { + let mut s = Session::new(); + new(&mut s, &json!({"contour": curve("x"), "name": "Mine"})).unwrap(); + let saved = s.presets.to_json(&s); + let mut t = Session::new(); + t.load_presets_json(saved); + assert_eq!(t.presets.contours.len(), 1); + assert_eq!(name_of(&t.presets.contours[0]), "Mine"); + assert_eq!(t.presets.contours, s.presets.contours); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + for p in + [json!({}), json!({"contour": 5}), json!({"contour": "Nope"}), json!({"contour": {"points": [[0, 0]]}}), json!({"contour": curve("x"), "name": 3})] + { + assert!(new(&mut s, &p).is_err(), "{p}"); + } + assert!(delete(&mut s, &json!({"name": "ghost"})).is_err()); + assert!(delete(&mut s, &json!({})).is_err()); + assert!(save(&mut s, &json!({})).is_err(), "empty library"); + new(&mut s, &json!({"contour": curve("x"), "name": "Existing"})).unwrap(); + let before = s.presets.contours.clone(); + for p in [ + json!({}), + json!({"data": 3}), + json!({"data": "!!"}), + json!({"data": "AAAA"}), + json!({"data": "AAAA", "mode": "replace"}), + json!({"data": "AAAA", "mode": "x"}), + json!({"path": "/nonexistent/x.shc"}), + ] { + assert!(load(&mut s, &p).is_err(), "{p}"); + assert_eq!(s.presets.contours, before, "{p}"); + } + assert_eq!(s.presets.contours, before); + let big = from_json(&json!([{"points": [[0, 0]]}, "Ring", 7, {"name": "Cone", "points": [[0, 0], [1, 1]]}])); + assert_eq!(big.len(), 1); + assert_eq!(name_of(&big[0]), "Cone 2"); + } + #[test] + fn malformed_persisted_library_keeps_existing_contours() { + let mut s = Session::new(); + new(&mut s, &json!({"contour": curve("x"), "name": "Existing"})).unwrap(); + let before = s.presets.contours.clone(); + for value in [json!(null), json!(3), json!("bad"), json!({})] { + s.load_presets_json(json!({"contours": value})); + assert_eq!(s.presets.contours, before); + } + s.load_presets_json(json!({"contours": []})); + assert!(s.presets.contours.is_empty()); + } +} diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 2f6e2b70b5..3984bc27a8 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -6,6 +6,7 @@ use photocraft_algo::matting::{self, RefineParams}; use photocraft_algo::segment::subject; use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; @@ -35,21 +36,32 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { check(&d.doc, crate::active_layer_of(s)?) } +/// Where the subject is looked for: the layer's own pixels within the canvas. A photo that +/// doesn't fill the canvas (placed, or the canvas grown) would otherwise stand out from the +/// empty canvas around it as the subject, and the mask would reveal its whole rectangle, which a +/// Stroke then outlines (#8235). The whole canvas when sampling all layers. +fn subject_area(l: &Layer, canvas: Rect, sample_all: bool) -> Rect { + let Some(s) = l.surface().filter(|_| !sample_all) else { return canvas }; + let empty_around = s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0); + let b = s.content_bounds().intersect(&canvas); + if empty_around && !b.is_empty() { b } else { canvas } +} + fn run(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?; + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check(doc, layer).map_err(EngineError::Other)?; let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false); let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true); + let area = subject_area(layer, doc.bounds(), sample_all); crate::jobs::edit_job( s, "Remove Background", move |doc, _, ctx| { // Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them. let found = ctx - .stage(0.1, 0.6, "Finding subject", |ctl| { - with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl)) - }) + .stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl))) .map_err(|_| EngineError::Cancelled)?; let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; if ctx.cancelled() { @@ -57,8 +69,8 @@ fn run(s: &mut Session, p: &Value) -> Result { } if refine { ctx.progress(0.6, "Refining edge"); - let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, d| { - matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, d.bounds(), &REFINE) + let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| { + matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, area, &REFINE) }); region = refined.unwrap_or(region); if ctx.cancelled() { @@ -66,7 +78,8 @@ fn run(s: &mut Session, p: &Value) -> Result { } } crate::extra_cmds::background_to_layer_for_mask(doc, id); - doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface: matting::region_surface(®ion), ..LayerMask::reveal_all() }); + let mask = crate::commands::mask_at_depth(LayerMask { surface: matting::region_surface(®ion), ..LayerMask::reveal_all() }, doc.depth)?; + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); doc.selection = None; let b = region.bbox; Ok([b.x0, b.y0, b.width() as i32, b.height() as i32]) @@ -94,7 +107,6 @@ mod tests { use super::*; use photocraft_algo::segment::Rng; use photocraft_doc::LayerId; - use photocraft_geom::Rect; fn session_with(w: u32, h: u32, depth: u32, px: impl Fn(u32, u32) -> [f32; 3]) -> Session { let mut s = Session::new(); @@ -287,4 +299,42 @@ mod tests { let masks = s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| l.mask.is_some()).count(); assert_eq!(masks, 1); } + + #[test] + fn remove_background_on_a_layer_smaller_than_the_canvas_masks_the_subject_not_the_layer() { + // The photo doesn't fill the canvas (a placed photo, or the canvas grown): the empty + // canvas around it is not the background, and a Stroke must not outline the photo's + // rectangle (#8235). + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("image.canvasSize", json!({"width": 260, "height": 220, "anchor": "center"})).unwrap(); + let id = active_id(&s); + s.execute(CMD, json!({})).unwrap(); + // The photo now sits at (30, 30). + let v = mask_iou(&s, 260, 220, |x, y| inside(x - 30, y - 30)); + assert!(v >= 0.75, "depth {depth}: IoU {v}"); + for (x, y) in [(31, 31), (228, 31), (31, 188), (228, 188), (40, 110)] { + assert!(mask_cov(&s, x, y) < 0.2, "depth {depth}: photo background at ({x}, {y}) revealed"); + } + s.edit("stroke", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects.items = vec![photocraft_doc::Effect::Stroke(photocraft_doc::StrokeFx { + common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + size: 3.0, + position: photocraft_doc::StrokePosition::Outside, + paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })]; + Ok(()) + }) + .unwrap(); + let img = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 260, 220)); + // Just outside the photo's left edge, and just inside it: no stroke. + for x in [27, 28, 29, 30, 31] { + let a = img.px[110 * 260 + x][3]; + assert!(a < 0.05, "depth {depth}: stroke along the layer's edge at x = {x} (alpha {a})"); + } + } + } } diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 1e4aafc909..49b36f9031 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -1,5 +1,5 @@ //! Colour-managed canvas display: the document's composite shown through document profile → -//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas +//! monitor profile, on the GPU canvas (matrix/shaper or display 3D LUT) and the CPU canvas //! (an 8-bit transform of the composite). //! //! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 0000000000..b51d164e74 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,58 @@ +//! Paths stored in a document reach the file system only through [`resolve`] (built on +//! [`photocraft_io::doc_path::resolve`]): linked smart objects, the Displace map and Color Lookup +//! file a smart filter re-reads, data-set images and video footage. A refused path is handled like +//! a missing file: the layer keeps its last rendering, and commands that need the file say why. +//! +//! Smart-object rendering has no `Session` argument, so the path of the document being worked on +//! travels in a thread-local scope, entered where commands run ([`crate::Session::edit`], command +//! dispatch, background jobs). Outside a scope there is no document: relative paths are refused +//! and only local absolute paths are followed. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +pub use photocraft_io::doc_path::NotFollowed; + +thread_local! { + static DOCUMENT: RefCell> = const { RefCell::new(None) }; +} + +/// Restores the previous document path when dropped. +pub(crate) struct DocumentScope(Option); + +impl Drop for DocumentScope { + fn drop(&mut self) { + let previous = self.0.take(); + DOCUMENT.with(|d| *d.borrow_mut() = previous); + } +} + +/// Resolve stored paths against `document` (the path of the document being worked on, `None` +/// for one without a file) until the returned scope is dropped. +pub(crate) fn scope(document: Option<&Path>) -> DocumentScope { + let next = document.filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf); + DocumentScope(DOCUMENT.with(|d| d.replace(next))) +} + +/// The document path of the current scope (to carry it to another thread). +pub(crate) fn current() -> Option { + DOCUMENT.with(|d| d.borrow().clone()) +} + +/// The file to open for `stored`, a path stored in the document of the current scope. +pub fn resolve(stored: &str) -> Result { + resolve_in(stored, current().as_deref()) +} + +/// The file to open for `stored`, a path stored in the document saved at `document`. +pub fn resolve_in(stored: &str, document: Option<&Path>) -> Result { + photocraft_io::doc_path::resolve(stored, document) +} + +/// The message for a stored path that is not followed. +pub(crate) fn refusal(what: &str, stored: &str, why: NotFollowed) -> crate::EngineError { + crate::EngineError::Other(format!("{what} {stored} is not opened: {why}")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs new file mode 100644 index 0000000000..1881860ae1 --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,257 @@ +//! Each kind of place that follows a path stored in a document goes through [`super::resolve`]: a +//! link to another computer is never followed, and a relative link next to the document is. + +use std::path::{Path, PathBuf}; + +use photocraft_doc::{LayerContent, LayerId, SmartSource}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +/// A link to a share on a TEST-NET-1 address, which never answers. +const REMOTE: &str = r"\\192.0.2.1\share\x.png"; + +/// An empty folder of its own (tests run in parallel). +fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-doc-path-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A 32×32 PNG whose pixel at column `x` is `rgb(x)`. +fn png(path: &Path, rgb: impl Fn(u32) -> [u8; 3]) { + let mut px = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + px.extend(rgb(x)); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); +} + +/// On Unix `//` followed by an absolute path names that same local file, while the resolver sees a +/// path on another computer: a place that skipped the resolver would read the file. +#[cfg(unix)] +fn network_alias(p: &Path) -> String { + format!("/{}", p.display()) +} + +/// A 32×32 white document saved (as far as its links know) at `d/doc.pcraft`, with `file` placed +/// linked → (session, layer id). +fn document(d: &Path, file: &Path) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + s.execute("file.placeLinked", json!({"path": file.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + (s, id) +} + +/// Point smart object `id` at `path` without re-rendering it. +fn relink(s: &mut Session, id: LayerId, path: &str) { + s.edit("Relink", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.source = SmartSource::Linked { path: path.into() }; + } + Ok(()) + }) + .unwrap(); +} + +fn centre(s: &Session) -> [f32; 3] { + let p = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(16, 16, 17, 17)).px[0]; + [p[0], p[1], p[2]] +} + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; + +fn update(s: &mut Session) -> Result { + s.execute("layer.smartObjects.updateModifiedContent", json!({})).map_err(|e| e.to_string()) +} + +#[test] +fn linked_smart_objects_follow_only_local_files() { + let d = dir("links"); + png(&d.join("red.png"), |_| [255, 0, 0]); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let (mut s, id) = document(&d, &d.join("red.png")); + assert_eq!(centre(&s), RED); + + relink(&mut s, id, REMOTE); + s.start_link_scan(); + assert!(s.link_scan.is_none(), "no look at a file on another computer"); + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []})); + let err = update(&mut s).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED, "the layer keeps its rendering"); + #[cfg(unix)] + { + relink(&mut s, id, &network_alias(&d.join("blue.png"))); + assert!(update(&mut s).is_err()); + assert_eq!(centre(&s), RED, "the file was not read"); + } + + // A relative link is taken from the document's folder. + relink(&mut s, id, "blue.png"); + update(&mut s).unwrap(); + assert_eq!(centre(&s), BLUE); + s.start_link_scan(); + assert!(s.link_scan.is_some(), "a followed link is looked at"); +} + +#[test] +fn package_copies_only_followed_links() { + let d = dir("package"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + #[allow(unused_mut)] + let mut remote = vec![REMOTE.to_string()]; + #[cfg(unix)] + remote.push(network_alias(&d.join("red.png"))); + for path in &remote { + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, path); + } + let r = s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy()})).unwrap(); + let mut missing: Vec = serde_json::from_value(r["missing"].clone()).unwrap(); + missing.sort(); + remote.sort(); + assert_eq!(missing, remote); + let links = r["links"].as_array().unwrap(); + assert_eq!(links.len(), 1); + assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}"); +} + +#[test] +fn psd_saves_embed_only_followed_links() { + let d = dir("psd"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, REMOTE); + let warnings = |r: serde_json::Value| r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect::>(); + let saved = warnings(s.execute("file.saveACopy", json!({"path": d.join("copy.psd").to_string_lossy()})).unwrap()); + assert!(saved.iter().any(|w| w.contains("red.png was embedded")), "{saved:?}"); + assert!(saved.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{saved:?}"); + // Package as PSD writes the copy of the document, whose remote link stays as it was. + let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap()); + assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}"); +} + +/// The pixels of the active document after its smart object `id` re-renders with its first smart +/// filter's params set to `params`. +fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32; 4]> { + s.edit("Params", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.smart_filters[0].params = params; + } + crate::smart_cmds::refresh(doc, id).map(|_| ()) + }) + .unwrap(); + let doc = &s.active().unwrap().doc; + photocraft_compose::render(doc, doc.bounds()).px +} + +#[test] +fn smart_filters_reread_only_followed_files() { + let d = dir("filters"); + png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]); + png(&d.join("map.png"), |_| [255, 255, 255]); + // An inverting look: corner (r, g, b) of the cube maps to (1-r, 1-g, 1-b), red fastest. + let corners = (0..8).map(|i| format!("{} {} {}\n", 1 - (i & 1), 1 - ((i >> 1) & 1), 1 - ((i >> 2) & 1))).collect::(); + std::fs::write(d.join("invert.cube"), format!("LUT_3D_SIZE 2\n{corners}")).unwrap(); + let path = |name: &str| d.join(name).to_string_lossy().into_owned(); + + // Displace re-reads its `mapPath`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path("map.png"), "horizontal": 50, "vertical": 0})).unwrap(); + let displaced = replayed(&mut s, id, json!({"mapPath": path("map.png"), "horizontal": 50, "vertical": 0})); + let unfiltered = replayed(&mut s, id, json!({"mapPath": REMOTE, "horizontal": 50, "vertical": 0})); + assert_ne!(displaced, unfiltered, "the map moves the pixels"); + assert_eq!(replayed(&mut s, id, json!({"mapPath": "map.png", "horizontal": 50, "vertical": 0})), displaced, "a relative map next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"mapPath": network_alias(&d.join("map.png")), "horizontal": 50, "vertical": 0})), unfiltered); + + // Color Lookup re-reads its LUT `file`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("image.adjustments.colorLookup", json!({"layer": id.0, "lut": "none"})).unwrap(); + let inverted = replayed(&mut s, id, json!({"file": path("invert.cube")})); + let unfiltered = replayed(&mut s, id, json!({"file": REMOTE})); + assert_ne!(inverted, unfiltered, "the look changes the pixels"); + assert_eq!(replayed(&mut s, id, json!({"file": "invert.cube"})), inverted, "a relative look next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered); +} + +/// #7423 (review on #7799): Displace on a Smart Object keeps a `mapPath` as a path, as before +/// (only a map from an open document is embedded). A path relative to the saved document, set +/// through the filter's own edit, displaces as the absolute one did, and the dialog's "None" +/// (`mapDocument: -1`) names no map instead of failing. +#[test] +fn smart_displace_keeps_a_relative_map_path() { + let d = dir("displace-relative"); + png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]); + png(&d.join("map.png"), |_| [255, 255, 255]); + let (mut s, id) = document(&d, &d.join("ramp.png")); + let path = d.join("map.png").to_string_lossy().into_owned(); + let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px; + s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap(); + let state = |s: &Session| { + let doc = &s.active().unwrap().doc; + let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") }; + (sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px) + }; + let (params, displaced) = state(&s); + assert_eq!(params["mapPath"], json!(path)); + assert!(photocraft_io::smart_map::displace_embedded_map(¶ms).is_none(), "a path, not an embedded map"); + assert_ne!(displaced, unfiltered, "the map moves the pixels"); + s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapPath": "map.png"}})).unwrap(); + let (params, relative) = state(&s); + assert_eq!(params["mapPath"], json!("map.png")); + assert_eq!(relative, displaced, "a relative map next to the document"); + s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapDocument": -1}})).unwrap(); + assert_eq!(state(&s).1, displaced, "\"None\" changes nothing"); +} + +#[test] +fn data_sets_and_footage_read_only_followed_files() { + let d = dir("datasets"); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "#ff0000"})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "img", "layer": layer.0, "type": "pixelReplacement", "method": "conform"}]})).unwrap(); + let set = |name: &str, path: &str| json!({"name": name, "values": [{"variable": "img", "kind": "pixels", "value": path}]}); + s.execute("image.variables.dataSets", json!({"dataSets": [set("remote", REMOTE), set("relative", "blue.png")]})).unwrap(); + let err = s.execute("image.applyDataSet", json!({"name": "remote"})).unwrap_err().to_string(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED); + s.execute("image.applyDataSet", json!({"name": "relative"})).unwrap(); + assert_eq!(centre(&s), BLUE); + + // Reload Frame re-reads the video layer's footage. + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": d.join("blue.png").to_string_lossy()})).unwrap(); + let footage = |s: &mut Session, path: &str| { + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("Footage", |doc, _| { + if let Some(v) = doc.layer_mut(id).and_then(|l| l.video.as_mut()) { + v.source = photocraft_doc::VideoSource::File { path: path.into() }; + } + Ok(()) + }) + .unwrap(); + s.execute("layer.videoLayers.reloadFrame", json!({})).map_err(|e| e.to_string()) + }; + let err = footage(&mut s, REMOTE).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + footage(&mut s, "blue.png").unwrap(); +} diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index 0bb409b818..e65856951b 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> { continue; } "name" => (value.is_string(), "a string".into()), + "profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()), _ => return Err(bad(cmd, format!("unknown parameter `{key}`"))), }; if !valid { @@ -101,6 +102,10 @@ pub struct DocumentSettings { pub background: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub background_color: Option<[f32; 3]>, + /// `file.new`'s Color Profile (#321): a built-in profile id or an .icc path; absent is the + /// working space, as in presets saved before it existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, /// Display units only; width/height are always pixels and resolution is always ppi. pub unit: String, pub resolution_unit: String, @@ -127,6 +132,9 @@ impl DocumentSettings { if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) { return Err("Background colour components must be within 0..1".into()); } + if self.profile.as_deref().is_some_and(|p| p.trim().is_empty()) { + return Err("A saved Color Profile needs a profile id or path".into()); + } if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") { return Err("Unknown dimension or resolution display unit".into()); } @@ -170,6 +178,9 @@ impl DocumentSettings { if let Some(c) = self.background_color { p["backgroundColor"] = json!(c); } + if let Some(profile) = &self.profile { + p["profile"] = json!(profile); + } p } } @@ -358,6 +369,27 @@ mod tests { next } + /// #321: a preset keeps New Document's Color Profile through a reload and back into + /// `file.new`; one saved without it (or before it existed) stays on the working space. + #[test] + fn the_color_profile_survives_save_reload_and_file_new() { + let mut s = Session::new(); + let mut config = settings(); + config["mode"] = json!("cmyk"); + config["profile"] = json!("coated-cmyk"); + let preset = saved(&mut s, "Press", config.clone()); + assert_eq!(preset["settings"]["profile"], "coated-cmyk"); + let next = reload(&s); + let params = next.presets.documents[0].settings.command_params(); + assert_eq!(params["profile"], "coated-cmyk"); + let mut s2 = Session::new(); + s2.execute("file.new", params).unwrap(); + assert!(s2.active().unwrap().doc.icc_profile.is_some()); + let plain = saved(&mut s, "Plain", settings()); + assert!(plain["settings"].get("profile").is_none()); + assert!(s.presets.documents[1].settings.command_params().get("profile").is_none()); + } + #[test] fn every_field_survives_save_reload_and_file_new() { for mode in ["rgb", "gray", "cmyk", "lab"] { @@ -495,6 +527,9 @@ mod tests { ("backgroundColor", json!([2, 0, 0])), ("unit", json!("yards")), ("resolutionUnit", json!("px")), + ("profile", json!("")), + ("profile", json!(" \t")), + ("profile", json!(12)), ] { let mut config = settings(); config[key] = bad; diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..29ce479c51 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -35,15 +35,14 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; -/// several selected layers are [`multi_copy_ids`]. +/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no +/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers +/// still copy their pixels; several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 && crate::layer_copy_cmds::layered(d.doc.mode) - && d.doc - .layer(id) - .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) + && d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_))) } /// A scratch document in the source's colour, to hold copied layers. @@ -280,13 +279,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, - // A group has no pixels of its own: the clipboard pixels are what it shows. + // A group or fill layer has no pixels of its own: the clipboard pixels are what it + // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas + // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors + // the clip to the OS clipboard (an empty clip would let a stale OS image replace it). (None, Some(copies)) => { let mut shown = (**copies).clone(); for l in &mut shown.layers { l.visible = true; } - Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + let clip = lift(&merged_surface(&shown), None, canvas)?; + let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; + Clip { layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -390,7 +394,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -399,8 +402,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } @@ -454,9 +456,9 @@ pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) return (0, 0); } let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))); - let (cx, cy) = c.unwrap_or(((canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0)); + let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0)); let b = clip.bounds; - ((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 2.0).round() as i32) + ((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32) } pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { @@ -1107,6 +1109,20 @@ mod tests { assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]); } + #[test] + fn paste_centers_clipboard_bounds_near_coordinate_limits() { + for origin in [i32::MAX - 512, i32::MIN + 512] { + let clip = Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None }; + let (dx, dy) = paste_offset(&clip, Rect::new(0, 0, 20, 20), &json!({}), false); + assert_eq!(i64::from(origin) + i64::from(dx), 9); + assert_eq!(i64::from(origin) + i64::from(dy), 9); + let clip = Clip { bounds: Rect::new(0, 0, 2, 2), ..clip }; + let (dx, dy) = paste_offset(&clip, Rect::from_xywh(origin, origin, 20, 20), &json!({}), false); + assert_eq!(dx, origin + 9); + assert_eq!(dy, origin + 9); + } + } + #[test] fn injected_clear_buffer_failure_leaves_the_document_unchanged() { let mut s = session(); @@ -1454,12 +1470,12 @@ mod tests { assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok()); - // An adjustment layer has no pixels of its own: Copy is only enabled while its mask is - // targeted (#2928). + // An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and + // its mask while that is targeted (#2928). s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.select", json!({"layer": a})).unwrap(); - assert!(!s.is_enabled("edit.copy"), "no pixels to copy"); + assert!(s.is_enabled("edit.copy"), "the layer itself can be copied"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied"); } @@ -1916,6 +1932,56 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy + /// takes them whole and Paste recreates them, in the same or another document, in one step. + #[test] + fn copy_paste_keeps_fill_and_adjustment_layers() { + for (depth, other) in [(8, 16), (16, 32), (32, 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") }; + let src = s.active().unwrap().active_layer.unwrap(); + assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied"); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows"); + let clip = s.clipboard.clone().unwrap(); + assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step"); + assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer"); + assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer"); + s.undo(); + assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste"); + s.redo(); + assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back"); + + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") }; + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas"); + s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background"); + } + } + + /// #2344: several adjustment layers, which show nothing alone, still copy and paste together. + #[test] + fn several_adjustment_layers_copy_together() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40])); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 5, "both are pasted"); + assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_)))); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 11e2273601..997c0daacd 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -13,6 +13,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use crate::channel_cmds::Target; use crate::commands::{CommandSpec, blend_from_str, int, layer_param}; use crate::{EngineError, Result, Session}; @@ -24,11 +25,23 @@ pub struct FadeSource { pub doc: DocId, /// Document revision right after the command; any later change disables Fade. pub revision: u64, - pub layer: LayerId, - pub mask: bool, + pub target: FadeTarget, pub label: String, } +/// What the faded command changed, and so what Fade blends back. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FadeTarget { + /// A layer's pixels. + Pixels(LayerId), + /// A layer's layer mask. + Mask(LayerId), + /// An alpha or spot channel (index into [`Document::channels`]). + Channel(usize), + /// The Quick Mask. + QuickMask, +} + /// A custom shape (Edit › Define Custom Shape), usable by the Custom Shape tool. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CustomShape { @@ -55,27 +68,42 @@ fn fadeable(id: &str) -> bool { } /// Called after every successful command: remember a fadeable result. -pub(crate) fn after_command(s: &mut Session, id: &str) { +pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) { if !fadeable(id) { return; } let Some(st) = s.active() else { return }; - let Some(layer) = st.active_layer else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); + // An alpha channel or the Quick Mask took the edit instead of the layer when the command ran + // on it (#7382): its explicit `target`, or the one the Channels panel gave it. Only commands + // routed to channels edit them; the others edit the layer whatever `target` says. + let run = crate::channel_cmds::inject_target(s, id, params.clone()); + let channel = match crate::channel_cmds::target_of(&run) { + _ if !crate::channel_cmds::routed(id) => None, + Target::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)), + Target::QuickMask if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), + _ => None, + }; + if let Some(target) = channel { + s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label }); + return; + } + let Some(layer) = st.active_layer else { return }; let mask = st.doc.layer(layer).is_some_and(|l| l.mask.is_some()) && st.history.state(st.history.past_len().wrapping_sub(1)).is_some_and(|prev| { // The mask changed and the pixels didn't: the command painted the mask. let now = st.doc.layer(layer); let was = prev.layer(layer); let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), (None, None) => true, _ => false, }; surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface())) && !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface))) }); - s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, layer, mask, label }); + let target = if mask { FadeTarget::Mask(layer) } else { FadeTarget::Pixels(layer) }; + s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label }); } /// Tile identity of a surface (pointer equality of its COW tiles). @@ -110,6 +138,12 @@ fn always(_: &Session) -> std::result::Result<(), String> { fn pixel_layer(s: &Session) -> std::result::Result { let d = s.active().ok_or("no document open")?; let id = d.active_layer.ok_or("no active layer")?; + pixel_layer_id(s, id) +} + +/// `id` if it is an unlocked pixel layer of the active document. +fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result { + let d = s.active().ok_or("no document open")?; let l = d.doc.layer(id).ok_or("no active layer")?; if !matches!(l.content, LayerContent::Raster(_)) { return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name())); @@ -138,15 +172,31 @@ fn can_fade(s: &Session) -> std::result::Result<(), String> { if f.doc != st.doc.id || f.revision != st.revision || !st.history.can_undo() { return Err("Fade is only available right after a filter, adjustment or painting step".into()); } - if st.doc.layer(f.layer).is_none() { - return Err("the faded layer no longer exists".into()); + if surface_of(&st.doc, f.target).is_none() { + return Err(match f.target { + FadeTarget::Pixels(_) | FadeTarget::Mask(_) => "the faded layer no longer exists".into(), + FadeTarget::Channel(_) | FadeTarget::QuickMask => "the faded channel no longer exists".into(), + }); } Ok(()) } -fn surface_of(doc: &Document, id: LayerId, mask: bool) -> Option<&Surface> { - let l = doc.layer(id)?; - if mask { l.mask.as_ref().map(|m| &m.surface) } else { l.surface() } +fn surface_of(doc: &Document, target: FadeTarget) -> Option<&Surface> { + match target { + FadeTarget::Pixels(id) => doc.layer(id)?.surface(), + FadeTarget::Mask(id) => doc.layer(id)?.mask.as_ref().map(|m| &m.surface), + FadeTarget::Channel(i) => doc.channels.get(i).map(|c| &c.surface), + FadeTarget::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + } +} + +fn surface_of_mut(doc: &mut Document, target: FadeTarget) -> Option<&mut Surface> { + match target { + FadeTarget::Pixels(id) => doc.layer_mut(id)?.surface_mut(), + FadeTarget::Mask(id) => doc.layer_mut(id)?.mask.as_mut().map(|m| &mut m.surface), + FadeTarget::Channel(i) => doc.channels.get_mut(i).map(|c| &mut c.surface), + FadeTarget::QuickMask => doc.quick_mask.as_mut().map(|c| &mut c.surface), + } } /// Blend `after` back towards `before` over `area`: `before + opacity × (B(before, after) − before)` @@ -202,17 +252,18 @@ fn fade(s: &mut Session, p: &Value) -> Result { let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; let mode = blend_from_str(str_or(p, "mode", "normal")).ok_or_else(|| bad(cmd, "unknown blend mode"))?; let st = s.active().ok_or(EngineError::NoDocument)?; - let prev = st.history.state(st.history.past_len() - 1).ok_or_else(|| bad(cmd, "no previous state"))?; - let before = surface_of(&prev, src.layer, src.mask).cloned().ok_or_else(|| EngineError::Other("the layer did not exist before this step".into()))?; - let now = surface_of(&st.doc, src.layer, src.mask).ok_or(EngineError::NoLayer(src.layer))?; + let prev = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).ok_or_else(|| bad(cmd, "no previous state"))?; + let before = surface_of(&prev, src.target).cloned().ok_or_else(|| EngineError::Other("the layer or channel did not exist before this step".into()))?; + let now = surface_of(&st.doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } - let area = before.content_bounds().union(&now.content_bounds()); + // A uniform mask can change every pixel without storing a single tile. In that case + // the content bounds are empty, but Fade must still blend across the canvas. + let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { - let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; - let surf = if src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(src.layer))?; + let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; fade_surface(&before, surf, area, mode, opacity)?; surf.prune(); Ok(()) @@ -323,7 +374,7 @@ fn can_caf(s: &Session) -> std::result::Result<(), String> { } /// Mask surface (gray) of an alpha channel by index or name. -fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { +pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { match v { Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface), Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface), @@ -358,38 +409,169 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result { content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection") } -fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { - use photocraft_algo::content_aware::{FillOptions, auto_sampling_with, color_level, fill_owned, rotation_level}; - let id = pixel_layer(s).map_err(EngineError::Other)?; - let st = s.active().ok_or(EngineError::NoDocument)?; - let doc = st.doc.clone(); - let sel = doc.selection.clone().ok_or_else(|| bad(cmd, "no selection"))?; - let hb = sel.content_bounds().intersect(&doc.bounds()); - if hb.is_empty() { +/// Everything one Content-Aware Fill needs, worked out from the document and the command's +/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows +/// is what OK and Apply write. +pub struct CafPlan { + pub layer: LayerId, + /// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom + /// area beyond it. + pub rect: Rect, + /// Photoshop's sampling window around the fill area (`content_aware::sampling_window`). + pub window: Rect, + pub fmt: PixelFormat, + /// What is sampled over `rect`: the target's pixels, or with Sample All Layers the visible + /// composite in the target's encoding. + pub img: Vec, + /// The target's own pixels over `rect` when they differ from `img` (Sample All Layers); see + /// [`CafPlan::layer_pixels`]. + pub layer_px: Option>, + /// The fill area (selection coverage, after the workspace's lasso edits) over `rect`. + pub cover: Vec, + pub hole: Vec, + /// What may be copied from: the sampling option's area before the brush strokes, and after. + pub base: Vec, + pub source: Vec, + pub opts: photocraft_algo::content_aware::FillOptions, + pub output: String, +} + +impl CafPlan { + /// The target layer's pixels over `rect`. + pub fn layer_pixels(&self) -> &[f32] { + self.layer_px.as_deref().unwrap_or(&self.img) + } +} + +/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points": +/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order. +#[derive(Clone, Debug, PartialEq)] +pub enum FillEdit { + Lasso { op: SelOp, points: Vec<[f64; 2]> }, + Grow(i32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelOp { + New, + Add, + Subtract, + Intersect, +} + +/// Most `fillEdits` and `samplingStrokes` one fill takes, and most points in all of them +/// together: the strokes and lassos are painted pixel by pixel, segment by segment. +pub const MAX_CAF_EDITS: usize = 1_000; +pub const MAX_CAF_POINTS: usize = 200_000; + +fn points_param(v: &Value, cmd: &str, what: &str, budget: &mut usize) -> Result> { + let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?; + *budget = budget.checked_sub(a.len()).ok_or_else(|| bad(cmd, format!("at most {MAX_CAF_POINTS} points in all fill edits and sampling strokes")))?; + a.iter() + .map(|p| { + let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + match (x, y) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))), + } + }) + .collect() +} + +/// `p[key]` as an array of at most [`MAX_CAF_EDITS`] entries (empty when absent). +fn edits_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a [Value]> { + let Some(a) = p.get(key) else { return Ok(&[]) }; + let a = a.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array")))?; + if a.len() > MAX_CAF_EDITS { + return Err(bad(cmd, format!("`{key}` takes at most {MAX_CAF_EDITS} entries"))); + } + Ok(a) +} + +fn fill_edits(p: &Value, cmd: &str, budget: &mut usize) -> Result> { + edits_param(p, "fillEdits", cmd)? + .iter() + .map(|e| { + let op = e.get("op").and_then(Value::as_str).unwrap_or(""); + Ok(match op { + "expand" | "contract" => { + let by = e + .get("by") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (0.0..=1000.0).contains(v)) + .ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?; + FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) }) + } + "new" | "add" | "subtract" | "intersect" => FillEdit::Lasso { + op: match op { + "new" => SelOp::New, + "add" => SelOp::Add, + "subtract" => SelOp::Subtract, + _ => SelOp::Intersect, + }, + points: points_param(e, cmd, "a lasso edit", budget)?, + }, + o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))), + }) + }) + .collect() +} + +fn sampling_strokes(p: &Value, cmd: &str, budget: &mut usize) -> Result> { + edits_param(p, "samplingStrokes", cmd)? + .iter() + .map(|e| { + let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") { + "add" => true, + "subtract" => false, + o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))), + }; + let size = e + .get("size") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (1.0..=5000.0).contains(v)) + .ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?; + Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke", budget)? }) + }) + .collect() +} + +/// A Content-Aware Fill's params, checked against the document before any pixel is read. +struct CafParams<'a> { + sel: &'a Surface, + /// The selection's bounds on the canvas. + sb: Rect, + edits: Vec, + strokes: Vec, + sampling: String, + custom_mask: Option<&'a Surface>, + custom_rect: Option, + opts: photocraft_algo::content_aware::FillOptions, + output: String, + surf: &'a Surface, +} + +fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Result> { + use photocraft_algo::content_aware::{FillOptions, color_level, rotation_level}; + let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?; + let sb = sel.content_bounds().intersect(&doc.bounds()); + if sb.is_empty() { return Err(bad(cmd, "the selection is outside the canvas")); } + let mut budget = MAX_CAF_POINTS; + let edits = fill_edits(p, cmd, &mut budget)?; + let strokes = sampling_strokes(p, cmd, &mut budget)?; let sampling = str_or(p, "sampling", "auto").to_string(); - let custom_mask: Option = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned(); + let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v)); let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None }; - // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` - // asks for a rectangle that far around the selection instead. - let window = match sampling.as_str() { - "auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()), - "rectangular" => match int(p, "margin") { - Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), - None => crate::fill_cmds::sampling_window(hb, doc.bounds()), - }, - "custom" => { - let r = match (custom_rect, custom_mask.as_ref()) { - (Some(r), _) => r, - (None, Some(m)) => m.content_bounds(), - (None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h] or `channel` (alpha channel index or name)")), - }; - r.union(&hb).inflate(4) - } + match sampling.as_str() { + "auto" | "rectangular" => {} + // The workspace's Custom: only what the Sampling Brush adds. + "custom" if custom_rect.is_some() || custom_mask.is_some() || strokes.iter().any(|s| s.add) => {} + "custom" => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")), other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))), } - .intersect(&doc.bounds()); let opts = FillOptions { color_adaptation: color_level(str_or(p, "colorAdaptation", "default")), rotations: rotation_level(str_or(p, "rotationAdaptation", "none")), @@ -402,6 +584,77 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & return Err(bad(cmd, "`output` must be current|new|duplicate")); } let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; + Ok(CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf }) +} + +/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`. +pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result { + content_aware_plan_with(doc, id, p, cmd, &photocraft_raster::Interrupt::NONE) +} + +/// [`content_aware_plan`], asking `ctl` row by row whether to stop ([`EngineError::Cancelled`]). +pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str, ctl: &photocraft_raster::Interrupt) -> Result { + use photocraft_algo::content_aware::{auto_sampling_with, grow_mask, paint_strokes, polygon_mask}; + use rayon::prelude::*; + + use crate::fill_cmds::{read_window, read_window_mask}; + let CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf } = caf_params(doc, id, p, cmd)?; + let canvas = doc.bounds(); + let cancel = || ctl.cancelled(); + let stop = || if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) }; + // The fill area: the selection, then the lasso edits, over a rectangle that holds them all. + let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum(); + let reach = edits.iter().fold(sb, |r, e| match e { + FillEdit::Lasso { points, .. } => { + points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1))) + } + FillEdit::Grow(_) => r, + }); + let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas); + let (ew, eh) = crate::fill_cmds::window_size(er)?; + let ecover = read_window_mask(sel, er, &cancel).ok_or(EngineError::Cancelled)?; + let mut ehole: Vec = ecover.par_iter().map(|c| *c > 0.0).collect(); + for e in &edits { + stop()?; + match e { + FillEdit::Lasso { op, points } => { + let m = polygon_mask(er, points); + for (h, v) in ehole.iter_mut().zip(&m) { + *h = match op { + SelOp::New => *v, + SelOp::Add => *h || *v, + SelOp::Subtract => *h && !*v, + SelOp::Intersect => *h && *v, + }; + } + } + FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by), + } + } + let (bx0, by0, bx1, by1) = mask_bounds(ew, eh, &ehole); + if bx1 <= bx0 { + return Err(bad(cmd, "the fill area is empty")); + } + let hb = Rect::new(er.x0 + bx0 as i32, er.y0 + by0 as i32, er.x0 + bx1 as i32, er.y0 + by1 as i32); + // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` + // asks for a rectangle that far around the fill area instead. + let window = match sampling.as_str() { + "rectangular" => match int(p, "margin") { + Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), + None => crate::fill_cmds::sampling_window(hb, canvas), + }, + "custom" => match (custom_rect, custom_mask) { + (Some(r), _) => r, + (None, Some(m)) => m.content_bounds(), + (None, None) => hb, + } + .union(&hb) + .inflate(4), + _ => crate::fill_cmds::sampling_window(hb, canvas), + } + .intersect(&canvas); + // Strokes that add to the sampling area may reach past the window. + let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas); let fmt = surf.format(); // Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active // layer has no pixels in the window there is nothing to sample, so that case uses the @@ -412,56 +665,134 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & }; let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). - let (w, h) = crate::fill_cmds::window_size(window)?; - // A background job when started with `Session::start` (#210): reading the window and the - // PatchMatch fill run on a worker against the document snapshot, cancellable per row band. + let (w, h) = crate::fill_cmds::window_size(rect)?; + let pixels = read_window(surf, rect, &cancel).ok_or(EngineError::Cancelled)?; + let (img, layer_px) = if sample_all { + stop()?; + (crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data, Some(pixels)) + } else { + (pixels, None) + }; + stop()?; + let mut cover = vec![0.0f32; w * h]; + let mut hole = vec![false; w * h]; + cover.par_chunks_mut(w.max(1)).zip(hole.par_chunks_mut(w.max(1))).enumerate().try_for_each(|(y, (crow, hrow))| { + stop()?; + let dy = rect.y0 + y as i32; + if dy < er.y0 || dy >= er.y1 { + return Ok(()); + } + for (x, (c, hl)) in crow.iter_mut().zip(hrow.iter_mut()).enumerate() { + let dx = rect.x0 + x as i32; + if dx >= er.x0 && dx < er.x1 { + let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize; + let (eh, ec) = (ehole.get(j).copied().unwrap_or(false), ecover.get(j).copied().unwrap_or(0.0)); + *hl = eh; + // Pixels the lasso added are filled outright; selected ones by their coverage. + *c = if eh { if ec > 0.0 { ec } else { 1.0 } } else { 0.0 }; + } + } + Ok::<(), EngineError>(()) + })?; + drop((ecover, ehole)); + // The sampling option's area: Auto keeps what looks like the surroundings within the window, + // Rectangular the whole window, Custom the given area (or nothing, for the brush to add to). + let mut base = vec![false; w * h]; + match sampling.as_str() { + "auto" | "rectangular" => { + let (ww, wh) = (window.width() as usize, window.height() as usize); + let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize); + let area: Vec = match sampling.as_str() { + "auto" if window == rect => auto_sampling_with(w, h, n, &img, &hole, ctl).map_err(|_| EngineError::Cancelled)?, + "auto" => { + let mut wimg = vec![0.0f32; ww * wh * n]; + let mut whole = vec![false; ww * wh]; + for y in 0..wh { + let s = ((oy + y) * w + ox) * n; + wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]); + whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]); + } + auto_sampling_with(ww, wh, n, &wimg, &whole, ctl).map_err(|_| EngineError::Cancelled)? + } + _ => vec![true; ww * wh], + }; + for y in 0..wh { + base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]); + } + } + _ => match (custom_rect, custom_mask) { + (Some(r), _) => base.par_iter_mut().enumerate().for_each(|(i, b)| *b = r.contains(rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32)), + (None, Some(m)) => { + let v = read_window_mask(m, rect, &cancel).ok_or(EngineError::Cancelled)?; + base.par_iter_mut().zip(&v).for_each(|(b, v)| *b = *v > 0.5); + } + (None, None) => {} + }, + } + stop()?; + base.par_iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h); + let mut source = base.clone(); + paint_strokes(&mut source, rect, &strokes); + source.par_iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h); + Ok(CafPlan { layer: id, rect, window, fmt, img, layer_px, cover, hole, base, source, opts, output }) +} + +fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { + use photocraft_algo::content_aware::fill_owned; + // The workspace names the layer it opened on: after an Apply to a new layer that one is + // active, but the fill still samples the original. + let id = match p.get("layer").and_then(Value::as_u64) { + Some(l) => pixel_layer_id(s, LayerId(l)), + None => pixel_layer(s), + } + .map_err(EngineError::Other)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let doc = st.doc.clone(); + // Check the params before the job starts. + caf_params(&doc, id, p, cmd)?; + let p = p.clone(); + // A background job when started with `Session::start` (#210): working out the fill area and + // the sampling area, reading the pixels and the PatchMatch fill run on a worker against the + // document snapshot, cancellable per row band. crate::jobs::run( s, label, true, move |ctx| { use rayon::prelude::*; - - use crate::fill_cmds::{read_window, read_window_cover, read_window_mask}; ctx.progress(0.0, label); let cancel = || ctx.cancelled(); - let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = if sample_all { - if cancel() { - return Err(EngineError::Cancelled); - } - let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt); - if cancel() { - return Err(EngineError::Cancelled); - } - region.data - } else { - read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)? - }; - let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; - let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { - ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), - ("custom", None, Some(m)) => read_window_mask(m, window, &cancel).ok_or(EngineError::Cancelled)?.par_iter().map(|v| *v > 0.5).collect(), - _ => vec![true; w * h], - }; - ctx.check()?; - // Auto: only what looks like the selection's surroundings. - if sampling == "auto" { - source = auto_sampling_with(w, h, n, &img, &hole, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|_| EngineError::Cancelled)?; + let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?; + let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan; + // Patches come only from the sampling area outside the fill area. With none (a + // Rectangular `margin` of 0, a Custom area inside the selection or off the canvas) + // the fill would be black (#6532), so refuse instead. The workspace's preview builds + // the same plan and may show an empty area while the Sampling Brush adds to it. + if !source.iter().any(|s| *s) { + return Err(bad(cmd, "nothing to sample from: the sampling area has no pixels outside the fill area")); } - ctx.check()?; - // The fill changes only the selected pixels: keep the original and the coverage over - // their bounds for the apply step, and fill the window's pixels themselves. + let (w, h) = (rect.width() as usize, rect.height() as usize); + let n = fmt.channels(); + // The fill changes only the fill area: keep the layer's pixels and the coverage over + // its bounds for the apply step, and fill the sampled pixels themselves. let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole); - let original: Vec = (by0..by1).flat_map(|y| img[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect(); - let at = |x: usize, y: usize| (window.x0 + x as i32, window.y0 + y as i32); - let ((x0, y0), (x1, y1)) = (at(bx0, by0), at(bx1, by1)); - let cover = read_window_mask(&sel, Rect::new(x0, y0, x1, y1), &cancel).ok_or(EngineError::Cancelled)?; + let own = layer_px.as_deref().unwrap_or(&img); + let original: Vec = (by0..by1).flat_map(|y| own[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect(); + let cover: Vec = (by0..by1).flat_map(|y| cover[y * w + bx0..y * w + bx1].iter().copied()).collect(); let n_hole = cover.iter().filter(|c| **c > 0.0).count(); - let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; - Ok(FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }) + ctx.check()?; + let mut filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; + // Sampled from all layers: outside the fill area the window keeps the layer's pixels. + if let Some(own) = layer_px { + filled.par_chunks_mut(n.max(1)).zip(own.par_chunks(n.max(1))).zip(hole.par_iter()).for_each(|((f, o), h)| { + if !*h { + f.copy_from_slice(o); + } + }); + } + Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole })) }, - move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done), + move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done), ) } @@ -902,13 +1233,13 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { return Ok(json!({"count": 0, "layers": 0})); } s.edit("Replace All Text", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for (id, matches) in &plan { if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) { for (a, b) in matches.iter().rev() { crate::type_cmds::replace_text(t, *a, *b, &replace); } - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } } Ok(()) @@ -926,10 +1257,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { { crate::type_cmds::check_text_unlocked(&doc, id)?; s.edit("Replace Text", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { crate::type_cmds::replace_text(t, a, b, &replace); - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } Ok(()) })?; @@ -1027,18 +1358,18 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result { let new = p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string(); match kind.as_str() { - "brushes" => { - // A renamed built-in is the user's preset now (and persists as one). - let b = &mut s.tools.presets[i]; - b.name = new; - b.builtin = false; - } + // Same rules as Brush Settings: no duplicate names (case-insensitive), the current + // preset follows, a renamed built-in is the user's preset now (and persists as one). + "brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?, "patterns" => s.patterns.items[i].name = new, _ => s.edit_state.custom_shapes[i].name = new, } } "delete" => match kind.as_str() { - "brushes" => drop(s.tools.presets.remove(i)), + "brushes" => { + let gone = s.tools.presets.remove(i); + crate::brush_cmds::forget_deleted_preset(s, &gone.name); + } "patterns" => drop(s.patterns.items.remove(i)), _ => drop(s.edit_state.custom_shapes.remove(i)), }, @@ -1066,6 +1397,7 @@ pub struct PresetFile { pub version: u32, pub brushes: Vec, pub custom_shapes: Vec, + pub tool_presets: Vec, } pub const PRESET_FORMAT: &str = "photocraft-presets"; @@ -1076,7 +1408,7 @@ fn export_import(s: &mut Session, p: &Value) -> Result { .get("kinds") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); + .unwrap_or_else(|| ["brushes", "customShapes", "tools"].iter().map(|k| k.to_string()).collect()); let want = |k: &str| kinds.iter().any(|x| x == k); match str_or(p, "action", "export") { "export" => { @@ -1095,8 +1427,14 @@ fn export_import(s: &mut Session, p: &Value) -> Result { Vec::new() }, custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() }, + tool_presets: if want("tools") { + let builtins = crate::presets::tools::builtin(); + s.presets.tool_presets.iter().filter(|preset| bool_or(p, "includeBuiltins", false) || !builtins.contains(preset)).cloned().collect() + } else { + Vec::new() + }, }; - Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len()})) + Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len(), "tools": file.tool_presets.len()})) } "import" => { let data = p.get("data").cloned().ok_or_else(|| bad(cmd, "missing `data` (an exported preset file)"))?; @@ -1108,6 +1446,29 @@ fn export_import(s: &mut Session, p: &Value) -> Result { if file.format != PRESET_FORMAT { return Err(bad(cmd, format!("not a preset file (format `{}`)", file.format))); } + // Validate the whole tool import before changing any preset library. + let mut tools = s.presets.tool_presets.clone(); + let nt = if want("tools") { file.tool_presets.len() } else { 0 }; + if nt > crate::presets::tools::MAX_PRESETS { + return Err(bad(cmd, "too many tool presets")); + } + if want("tools") { + for mut preset in file.tool_presets { + preset.name = crate::presets::tools::checked_name(&preset.name, cmd)?; + if preset.tool.trim().is_empty() || !preset.options.is_object() { + return Err(bad(cmd, "tool presets require a non-empty tool and an options object")); + } + if let Some(old) = tools.iter_mut().find(|old| old.name == preset.name && old.tool.eq_ignore_ascii_case(&preset.tool)) { + *old = preset; + } else if tools.iter().any(|old| old.name == preset.name) { + return Err(bad(cmd, "a preset of that name belongs to another tool; rename it before importing")); + } else if tools.len() < crate::presets::tools::MAX_PRESETS { + tools.push(preset); + } else { + return Err(bad(cmd, "too many tool presets")); + } + } + } let (mut nb, mut ns) = (0, 0); if want("brushes") { for mut b in file.brushes { @@ -1132,7 +1493,11 @@ fn export_import(s: &mut Session, p: &Value) -> Result { ns += 1; } } - Ok(json!({"brushes": nb, "customShapes": ns})) + if want("tools") { + s.presets.tool_presets = tools; + s.presets_changed(); + } + Ok(json!({"brushes": nb, "customShapes": ns, "tools": nt})) } other => Err(bad(cmd, format!("unknown action `{other}` (export|import)"))), } @@ -1169,7 +1534,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##, can_caf, content_aware_fill ), @@ -1224,7 +1589,7 @@ pub fn specs() -> Vec { "Export/Import Presets…", ["Edit", "Presets"], None, - r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, + r##"{"action":"export|import","kinds":["brushes","customShapes","tools"]?,"data":json (import),"includeBuiltins":bool=false}"##, always, export_import ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index f7c2f23e83..21d667ecce 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -57,6 +57,101 @@ fn fade_with_blend_mode_and_invalidation() { assert!(s.execute("edit.fade", json!({"mode": "nonsense"})).is_err()); } +#[test] +fn fade_uniform_layer_mask_invert_at_all_depths() { + for depth in [8, 16, 32] { + for (opacity, expected) in [(0, 1.0), (50, 0.5), (100, 0.0)] { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask_pixel = |s: &Session| { + let layer = active(s); + let mask = &layer.mask.as_ref().unwrap().surface; + mask.pixel(5, 5)[0] + }; + assert!((mask_pixel(&s) - 1.0).abs() < 0.01); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| matches!(f.target, FadeTarget::Mask(_))), "depth {depth}: Fade must target the mask"); + let history_before_fade = s.active().unwrap().history.past_len(); + + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let pixel = active(&s).mask.as_ref().unwrap().surface.pixel(x, y)[0]; + assert!((pixel - expected).abs() < 0.02, "depth {depth}, opacity {opacity}, ({x}, {y}): {pixel}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history_before_fade + 1); + assert!(s.undo()); + assert!(mask_pixel(&s) < 0.01, "undo restores inverted mask"); + assert!(s.redo()); + assert!((mask_pixel(&s) - expected).abs() < 0.02, "redo restores faded mask"); + } + } +} + +/// #7382: with an alpha channel or the Quick Mask targeted, the edit changes it, not the layer, and +/// Fade blends it back: at 0 % to the channel as it was, at every depth. +#[test] +fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { + for depth in [8, 16, 32] { + for quick_mask in [false, true] { + for (opacity, k) in [(0, 0.0), (25, 0.25), (100, 1.0)] { + let mut s = session(depth); + if quick_mask { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + } else { + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + } + let channel = |s: &Session, x, y| { + let doc = &s.active().unwrap().doc; + let c = if quick_mask { doc.quick_mask.as_ref() } else { doc.channels.first() }; + c.unwrap().surface.pixel(x, y)[0] + }; + let was = channel(&s, 5, 5); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted"); + let expected = was + (1.0 - 2.0 * was) * k; + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let v = channel(&s, x, y); + assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}"); + } + assert_eq!(px(&s, 5, 5)[0], 1.0, "the layer is left alone"); + let st = s.active().unwrap(); + assert_eq!((st.history.past_len(), st.history.undo_label()), (steps + 1, Some("Fade Invert"))); + assert!(s.undo()); + assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "undo puts the inverted channel back"); + } + } + } +} + +/// A command told to edit the layer (`"target": "pixels"`) does so with an alpha channel targeted, +/// and Fade then fades the layer, not the channel. +#[test] +fn fade_follows_the_target_the_command_ran_on() { + let mut s = session(8); + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000", "target": "pixels"})).unwrap(); + assert_eq!(px(&s, 5, 5)[0], 0.0, "the layer was filled"); + assert!(matches!(s.edit_state.fade.as_ref().map(|f| f.target), Some(FadeTarget::Pixels(_)))); + s.execute("edit.fade", json!({"opacity": 25})).unwrap(); + assert!((px(&s, 5, 5)[0] - 0.75).abs() < 0.01, "the layer faded"); + assert_eq!(s.active().unwrap().doc.channels[0].surface.pixel(5, 5)[0], 0.0, "the channel is left alone"); +} + +/// A command that only edits layers (Auto Tone) fades the layer, even given a channel `target`. +#[test] +fn fade_ignores_a_target_the_command_doesnt_take() { + let mut s = session(8); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.autoTone", json!({"target": "quickMask"})).unwrap(); + assert!(matches!(s.edit_state.fade.as_ref().map(|f| f.target), Some(FadeTarget::Pixels(_)))); +} + #[test] fn purge_commands() { let mut s = session(8); @@ -105,10 +200,15 @@ fn blob_session(depth: u32) -> Session { fn content_aware_fill_huge_area_neither_panics_nor_wraps() { let mut s = blob_session(8); // `[1e30, 0, 1e30, 10]` overflowed the i32 additions in the area parser (a debug-build - // panic, a garbage sampling window in release); the additions saturate now, so this is - // simply a whole-canvas window. - let r = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap(); - assert!(r["filled"].as_u64().unwrap() > 100); + // panic, a garbage sampling window in release); the additions saturate now, so the area lies + // wholly right of the canvas: nothing to sample, which is an error, not a black fill (#6532). + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap_err(); + assert!(err.to_string().contains("nothing to sample from"), "{err}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "the document changed"); + assert_eq!(st.history.past_len(), past, "a history step was recorded"); // A malformed area names the problem instead of silently falling back. for area in [json!([0.0, 0.0, null, 10.0]), json!([0.0, 0.0, 10.0]), json!("nope")] { let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": area})).unwrap_err(); @@ -266,6 +366,69 @@ fn content_aware_fill_samples_photoshops_window() { } } +#[test] +fn content_aware_fill_takes_the_workspace_edits() { + // The plan the workspace previews: lasso edits change the fill area (and the window follows), + // sampling-brush strokes change what may be copied, also outside the window. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap(); + let st = s.active().unwrap(); + let (doc, id) = (st.doc.clone(), st.active_layer.unwrap()); + let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill"); + let count = |m: &[bool]| m.iter().filter(|v| **v).count(); + let base = plan(json!({"sampling": "rectangular"})).unwrap(); + assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500)); + assert_eq!(count(&base.source), 200 * 200 - 2500); + // A lasso square added beside the selection: a bigger fill area, a window around both. + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap(); + assert_eq!(count(&p.hole), 5000); + assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window); + // Expand by 2 grows the 50 px square to about 54 px (rounded corners). + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap(); + assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole)); + // Subtracting it all leaves nothing to fill. + assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err()); + // A subtracting stroke takes a band out of the sampling area; an adding one far away reaches + // past the window, and the plan's rectangle grows to hold it. + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap(); + assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60); + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap(); + assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350)); + assert!(count(&p.source) > count(&base.source) + 600); + // Custom from strokes alone; bad edits are errors. + let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap(); + assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source)); + for bad in [ + json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}), + json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}), + json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}), + json!({"fillEdits": [{"op": "expand", "by": -3}]}), + ] { + assert!(plan(bad.clone()).is_err(), "{bad}"); + } + // The command fills with the same plan; `layer` names the layer even when another is active. + let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 2800); +} + +/// #6532: a sampling area with no pixels outside the fill area is refused instead of filling it +/// black, and leaves the document untouched; any margin past the selection fills. +#[test] +fn content_aware_fill_with_nothing_to_sample_is_refused() { + let mut s = blob_session(8); + let before = px(&s, 30, 22); + let revision = s.active().unwrap().revision; + for p in [json!({"sampling": "rectangular", "margin": 0}), json!({"sampling": "custom", "area": [28, 18, 4, 4]})] { + let e = s.execute("edit.contentAwareFill", p.clone()).unwrap_err().to_string(); + assert!(e.contains("nothing to sample from"), "{p}: {e}"); + assert_eq!(px(&s, 30, 22), before, "{p}: nothing was filled"); + assert_eq!(s.active().unwrap().revision, revision, "{p}: no history step"); + } + s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "margin": 1})).unwrap(); + assert_ne!(px(&s, 30, 22), before, "the red blob is filled from around the selection"); +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); @@ -427,6 +590,35 @@ fn define_custom_shape_and_preset_manager() { assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "import", "data": {"format": "other"}})).is_err()); } +#[test] +fn preset_manager_brush_rename_follows_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Chalk", "newName": "Chalky"})).unwrap(); + assert_eq!(s.tools.current_preset.as_deref(), Some("Chalky")); + s.execute("brush.presets.update", json!({})).unwrap(); +} + +#[test] +fn preset_manager_brush_rename_refuses_duplicate_name() { + let mut s = Session::new(); + let before: Vec = s.tools.presets.iter().map(|b| b.name.clone()).collect(); + let r = s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "hard round"})); + assert!(r.is_err(), "a case-insensitive duplicate name must be refused"); + assert_eq!(s.tools.presets.iter().map(|b| b.name.clone()).collect::>(), before); + // Renaming to a different case of its own name is fine. + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "soft round"})).unwrap(); +} + +#[test] +fn preset_manager_brush_delete_clears_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "delete", "kind": "brushes", "name": "Chalk"})).unwrap(); + assert!(!s.tools.presets.iter().any(|b| b.name == "Chalk")); + assert_eq!(s.tools.current_preset, None); +} + #[test] fn define_brush_preset_wraps_define_from_selection() { let mut s = session(8); @@ -564,3 +756,49 @@ fn fade_allocation_failure_keeps_document_and_history() { assert_eq!(*s.active().unwrap().doc, *before); } } + +#[test] +fn tool_presets_round_trip_through_export_import() { + let mut source = Session::new(); + source.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"2:3"}}})).unwrap(); + let exported = source.execute("edit.presets.exportImportPresets", json!({"action":"export"})).unwrap(); + assert_eq!(exported["tools"], 1); + assert_eq!(exported["data"]["toolPresets"][0]["name"], "2 : 3"); + let mut target = Session::new(); + target.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"1:1"}}})).unwrap(); + for _ in 0..2 { + let result = target.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":exported["data"]})).unwrap(); + assert_eq!(result["tools"], 1); + } + let selected = target.execute("tool.presets.select", json!({"preset":"2 : 3"})).unwrap(); + assert_eq!(selected["options"]["toolOptions"]["crop_ratio"], "2:3"); + assert_eq!(target.presets.tool_presets.iter().filter(|p| p.name == "2 : 3").count(), 1); + let only_brushes = source.execute("edit.presets.exportImportPresets", json!({"kinds":["brushes"]})).unwrap(); + assert_eq!(only_brushes["tools"], 0); + let with_builtins = source.execute("edit.presets.exportImportPresets", json!({"kinds":["tools"], "includeBuiltins":true})).unwrap(); + assert_eq!(with_builtins["tools"].as_u64().unwrap() as usize, source.presets.tool_presets.len()); +} + +#[test] +fn tool_preset_import_is_validated_before_mutation_and_accepts_old_files() { + let mut s = Session::new(); + let before = s.presets.tool_presets.clone(); + for preset in [ + json!({"name":"", "tool":"crop", "options":{}}), + json!({"name":"x".repeat(256), "tool":"crop", "options":{}}), + json!({"name":"Crop", "tool":" ", "options":{}}), + json!({"name":"Crop", "tool":"crop", "options":[]}), + ] { + let result = s.execute( + "edit.presets.exportImportPresets", + json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":[{"name":"Valid", "tool":"crop", "options":{}}, preset]}}), + ); + assert!(result.is_err()); + assert_eq!(s.presets.tool_presets, before); + } + let too_many: Vec<_> = (0..crate::presets::tools::MAX_PRESETS + 1).map(|i| json!({"name":format!("Crop {i}"), "tool":"crop", "options":{}})).collect(); + assert!(s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":too_many}})).is_err()); + assert_eq!(s.presets.tool_presets, before); + let result = s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "version":1}})).unwrap(); + assert_eq!(result["tools"], 0); +} diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 920c96a014..c702b16b75 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -117,8 +117,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result { Ok(damage(s, dmg, true)) } -/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one. -fn background_eraser(s: &mut Session, p: &Value) -> Result { +/// The stroke, settings and layer of a `paint.backgroundEraser` (shared by the command and its +/// live preview), after the same checks. +fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSettings, LayerId)> { const CMD: &str = "paint.backgroundEraser"; let pts = crate::brush_cmds::parse_points(p, CMD)?; if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite() || q.x.abs() > 1e7 || q.y.abs() > 1e7) { @@ -134,17 +135,21 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { background: s.tools.background, }; let id = active_pixel_layer(s, CMD)?; - { - let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if let Some(e) = pixels_locked(&d.doc, l) { - return Err(e); - } - if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) { - return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); - } + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if let Some(e) = pixels_locked(&d.doc, l) { + return Err(e); } - let stroke = Stroke { brush, points: pts }; + if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) { + return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); + } + Ok((Stroke { brush, points: pts }, bs, id)) +} + +/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one. +fn background_eraser(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "paint.backgroundEraser"; + let (stroke, bs, id) = background_eraser_setup(s, p)?; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); crate::extra_cmds::background_to_layer(doc, id); @@ -155,6 +160,24 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { Ok(damage(s, dmg, true)) } +/// A `paint.backgroundEraser` stroke shown while it is drawn: the same params, checks and +/// erasing per dab as the commit (on a Background already turned into a normal layer), so the +/// preview at the last point is the committed result. +pub fn live_background_eraser(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.backgroundEraser"; + let (stroke, bs, id) = background_eraser_setup(s, p)?; + let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + doc.layer(id).ok_or(EngineError::NoLayer(id))?; + crate::extra_cmds::background_to_layer(&mut doc, id); + let stroke = background_eraser_stroke(&stroke); + crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, true, |pre| match background_eraser_effect(pre, &bs) { + Some(effect) => Box::new(effect), + // No alpha: the commit erases nothing either. + None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), + })? + .with_tail_preview() +} + /// Eraser-group command specs. pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/eraser_cmds/tests.rs b/crates/engine/src/eraser_cmds/tests.rs index 1946f346f9..07866aa72f 100644 --- a/crates/engine/src/eraser_cmds/tests.rs +++ b/crates/engine/src/eraser_cmds/tests.rs @@ -275,3 +275,58 @@ fn bench_magic_eraser_24mp() { let t3 = t0.elapsed().as_secs_f64() * 1000.0; println!("phases: sample {t1:.1} ms, wand {:.1} ms, erase {:.1} ms", t2 - t1, t3 - t2); } + +/// The live Background Eraser shows exactly what the commit erases, fed point by point: on the +/// Background (which the commit turns into a normal layer first), with every sampling mode, at +/// 8 and 16 bits (the live working pixels are floats between dabs, as the commit's are). +#[test] +fn live_background_eraser_matches_the_commit() { + let all = [[6.0, 20.0], [18.0, 22.0], [30.0, 18.0], [44.0, 24.0], [56.0, 20.0]]; + for depth in [8, 16] { + for sampling in ["continuous", "once", "backgroundSwatch"] { + let mut s = session("rgb", depth); + s.execute("tools.setColors", json!({"background": "#000000"})).unwrap(); + let opts = |pts: &[[f64; 2]]| json!({"points": pts, "size": 14, "hardness": 0.6, "sampling": sampling, "limits": "discontiguous", "tolerance": 30, "seed": 7}); + let (doc, h) = (s.active().unwrap().doc.clone(), history_len(&s)); + let mut live = live_background_eraser(&s, &opts(&all[..1])).unwrap(); + for q in &all[1..] { + live.push(&[photocraft_paint::StrokePoint::new(q[0], q[1], 1.0)]).unwrap(); + } + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc) && history_len(&s) == h, "the preview leaves the document alone"); + let id = st.active_layer.unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().clone(); + s.execute("paint.backgroundEraser", opts(&all)).unwrap(); + assert_eq!(history_len(&s), h + 1, "one History step"); + let mut erased = false; + for y in 0..48 { + for x in 0..64 { + let (got, want) = (shown.rgba(x, y), surface(&s).rgba(x, y)); + erased |= want[3] < 0.5; + // The colour under a fully erased pixel doesn't show. + if got[3] == 0.0 && want[3] == 0.0 { + continue; + } + assert_eq!(got, want, "{sampling} {depth}-bit ({x}, {y})"); + } + } + assert!(erased, "{sampling} {depth}: the stroke erased something"); + } + } +} + +#[test] +fn live_background_eraser_refuses_what_the_command_refuses() { + let mut s = session("rgb", 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + let p = json!({"points": [[20, 20]]}); + assert!(live_background_eraser(&s, &p).is_err()); + assert!(s.execute("paint.backgroundEraser", p).is_err()); + let s = session("rgb", 8); + for p in [json!({}), json!({"points": []}), json!({"points": [[f64::NAN, 0]]}), json!({"points": [[1e300, 0]]}), json!({"points": [[0, 0]], "sampling": 3})] + { + assert!(live_background_eraser(&s, &p).is_err(), "{p}"); + } + assert!(live_background_eraser(&Session::new(), &json!({"points": [[0, 0]]})).is_err()); +} diff --git a/crates/engine/src/export_preset_cmds.rs b/crates/engine/src/export_preset_cmds.rs index 3d56c22437..7378e703ab 100644 --- a/crates/engine/src/export_preset_cmds.rs +++ b/crates/engine/src/export_preset_cmds.rs @@ -12,6 +12,33 @@ const MAX_PRESETS: usize = 256; const MAX_PX: u32 = 300_000; const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"]; +/// Export As › Image Size › Resample, in Photoshop's order: the `image.imageSize` method each +/// choice sends, and its menu label. The first one is the default. +pub const RESAMPLE: [(&str, &str); 6] = [ + ("bicubicAutomatic", "Bicubic Automatic"), + ("bicubic", "Bicubic"), + ("bicubicSmoother", "Bicubic Smoother"), + ("bicubicSharper", "Bicubic Sharper"), + ("bilinear", "Bilinear"), + ("nearest", "Nearest Neighbor"), +]; + +/// Photoshop's Export As default: Bicubic Automatic. +pub const DEFAULT_RESAMPLE: &str = "bicubicAutomatic"; + +/// `v` when it names one of [`RESAMPLE`]; otherwise an error listing the choices. Export As +/// never falls back silently: Image Size would quietly use Bicubic for an unknown name. +pub fn export_resample(v: &str) -> std::result::Result<&'static str, String> { + RESAMPLE.iter().map(|(k, _)| *k).find(|k| *k == v).ok_or_else(|| { + let names: Vec<&str> = RESAMPLE.iter().map(|(k, _)| *k).collect(); + format!("resample must be one of {}", names.join(", ")) + }) +} + +fn default_resample() -> String { + DEFAULT_RESAMPLE.into() +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportPresetSettings { @@ -33,6 +60,9 @@ pub struct ExportPresetSettings { pub embed_profile: bool, /// `"auto"`, `"24"` or `"32"`. pub tga_bits: String, + /// One of [`RESAMPLE`]; presets saved before the choice existed load as the default. + #[serde(default = "default_resample")] + pub resample: String, } impl ExportPresetSettings { @@ -61,6 +91,7 @@ impl ExportPresetSettings { if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") { return Err("tgaBits must be auto, 24 or 32".into()); } + export_resample(&self.resample)?; Ok(()) } @@ -79,7 +110,8 @@ impl ExportPresetSettings { "metadata": "none", "profile": "document", "embedProfile": true, - "tgaBits": "auto" + "tgaBits": "auto", + "resample": DEFAULT_RESAMPLE }); if let Some(defaults) = defaults.as_object() { for (k, v) in defaults { @@ -196,7 +228,7 @@ pub fn specs() -> Vec { label: "Save Export Preset", menu: &[], shortcut: None, - params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#, + params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32","resample":"bicubicAutomatic|bicubic|bicubicSmoother|bicubicSharper|bilinear|nearest"="bicubicAutomatic"}} (a saved name is replaced)"#, enabled: always, run: save, journal: false, @@ -232,7 +264,8 @@ mod tests { "metadata": "exif", "profile": "srgb", "embedProfile": false, - "tgaBits": "24" + "tgaBits": "24", + "resample": "nearest" }) } @@ -255,6 +288,7 @@ mod tests { assert_eq!(presets[0]["settings"]["width"], 640); assert_eq!(presets[0]["settings"]["embedProfile"], false); assert_eq!(presets[0]["settings"]["metadata"], "exif"); + assert_eq!(presets[0]["settings"]["resample"], "nearest"); let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap(); assert_eq!(got["preset"]["settings"]["profile"], "srgb"); @@ -276,6 +310,9 @@ mod tests { assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err()); assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err()); assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err()); + let err = s.execute("file.export.presets.save", json!({"name": "x", "settings": {"resample": "lanczos"}})).unwrap_err().to_string(); + assert!(err.contains("resample must be one of"), "{err}"); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"resample": 3}})).is_err()); assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err()); assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err()); @@ -286,5 +323,6 @@ mod tests { ])); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].name, "ok"); + assert_eq!(loaded[0].settings.resample, DEFAULT_RESAMPLE, "a preset saved before Resample existed loads with the default"); } } diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index 908942d4fd..5fbc355314 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -52,7 +52,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result { precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false), }; let bytes = read_file(&path)?; - let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document); let (i, color) = s.open_document(r.document, Some(path.clone())); Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } @@ -173,6 +174,25 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// File Handling › "Open color-less EXR as ACEScg" applies to Open EXR Parts too. + #[test] + fn open_exr_parts_honours_the_aces_cg_preference() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-aces-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = session(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.icc_profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() { let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id())); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index c291862167..4e2a265ab9 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -131,11 +131,16 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut Ok(Value::Null) } +/// The effective whole-pixel Stroke width, shared by execution and ATN export. +pub(crate) fn stroke_width(p: &Value) -> usize { + // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). + p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize +} + /// Edit › Stroke: a band along the selection edge (or the layer's opaque edge without a selection). fn stroke(s: &mut Session, p: &Value) -> Result { - // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). - let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; - let color = color_param(p, "color", s.tools.foreground); + let width = stroke_width(p); + let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { @@ -278,72 +283,24 @@ fn average(s: &mut Session, p: &Value) -> Result { }) } -fn hash(x: i32, y: i32, seed: u32) -> f32 { - let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); - h ^= h >> 13; - h = h.wrapping_mul(0x5bd1_e995); - h ^= h >> 15; - (h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32 -} - -/// Smooth value noise, summed over octaves (fBm), in 0..1. -pub(crate) fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 { - let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0); - for o in 0..8u32 { - let (fx, fy) = (x * freq, y * freq); - let (ix, iy) = (fx.floor() as i32, fy.floor() as i32); - let (tx, ty) = (fx - ix as f32, fy - iy as f32); - let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty)); - let s = seed.wrapping_add(o * 7919); - let top = hash(ix, iy, s) + (hash(ix + 1, iy, s) - hash(ix, iy, s)) * sx; - let bot = hash(ix, iy + 1, s) + (hash(ix + 1, iy + 1, s) - hash(ix, iy + 1, s)) * sx; - sum += (top + (bot - top) * sy) * amp; - norm += amp; - amp *= 0.5; - freq *= 2.0; - } - // Stretch the bell-shaped sum so the clouds use the full foreground→background range. - ((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0) -} - -/// Filter › Render › Clouds / Difference Clouds, between the foreground and background colours. -fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { - let (fg, bg) = (s.tools.foreground, s.tools.background); - let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0) as u32; - let size = s.active().map_or(256.0, |d| d.doc.size.width.max(d.doc.size.height) as f32); - let base = (size / 4.0).clamp(16.0, 512.0); - map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, p, |area, px, _| { - let w = area.width() as usize; - for (i, p) in px.iter_mut().enumerate() { - let (x, y) = ((area.x0 + (i % w) as i32) as f32, (area.y0 + (i / w) as i32) as f32); - let t = clouds_value(x, y, base, seed); - let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t); - if difference { - for k in 0..3 { - p[k] = (p[k] - c[k]).abs(); - } - } else { - *p = [c[0], c[1], c[2], 1.0]; - } - } - }) -} - // ---------- transform presets ---------- /// The presets' params: like Free Transform, they follow the Channels and Layers panel target. const TRANSFORM_TARGET: &str = r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#; +/// Flips and turns about the centre of the box Free Transform would open: with a selection +/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All +/// mirrors across the canvas (#6928); otherwise the content's bounds. fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; // A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835). let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? { - Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})), + Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})), None => { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - (crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0})) + (crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0})) } }; if b.is_empty() { @@ -869,7 +826,9 @@ pub fn specs() -> Vec { if all == d.doc.bounds() { return Ok(json!({"changed": false})); } - s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?; + // One History step named as in Photoshop, not the crop's "Crop". + let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); + crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; Ok(json!({"changed": true})) }), spec!( @@ -928,6 +887,7 @@ pub fn specs() -> Vec { gray }; for id in ids { + background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); // Pass Through is a group's mode: a group's style pastes onto other layers as @@ -1052,8 +1012,6 @@ pub fn specs() -> Vec { show_only ), spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average), - spec!("filter.render.clouds", "Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, false)), - spec!("filter.render.differenceClouds", "Difference Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, true)), ] } @@ -1469,6 +1427,49 @@ mod tests { assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]); } + /// #6928: with a selection, the presets flip and turn about the selection's own bounds, as + /// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre. + /// Without a selection they keep turning about the content (above). + #[test] + fn transform_presets_turn_about_the_selection() { + let mut s = session(8); + paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]); + paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]); + let green = vec![0.0, 1.0, 0.0, 1.0]; + // Select All on the 40 × 30 canvas: across the canvas's centre lines. + s.execute("select.all", json!({})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14)); + assert_eq!(pixel(&s, 29, 10), green); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step"); + s.execute("edit.transform.flipVertical", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20)); + assert_eq!(pixel(&s, 29, 19), green); + s.execute("edit.transform.rotate180", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + assert_eq!(pixel(&s, 10, 10), green); + // A marquee (0, 0)–(24, 30) holding the whole square: across x = 12. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14)); + assert_eq!(pixel(&s, 13, 10), green); + // Undo puts it back where it was. + s.undo(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + // A targeted alpha channel turns about the selection's bounds too. + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20)); + } + #[test] fn reselect_restores_the_last_selection() { let mut s = session(8); @@ -1493,6 +1494,31 @@ mod tests { } } + /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's + /// clouds, as when Clouds sized itself by the document (before it became a filter). + #[test] + fn clouds_in_a_selection_are_the_canvas_clouds() { + // Big enough that the canvas (cloud size 100 px) and the selection (16 px) size differently. + let big = |depth: u32| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s + }; + for depth in [8, 16, 32] { + let mut whole = big(depth); + whole.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); + let mut s = big(depth); + s.execute("select.rect", json!({"x": 50, "y": 40, "width": 60, "height": 50})).unwrap(); + s.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); + for (x, y) in [(50, 40), (75, 60), (100, 85), (109, 89)] { + let (a, b) = (pixel(&s, x, y), pixel(&whole, x, y)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "depth {depth} at {x},{y}: {a:?} vs {b:?}"); + } + assert_eq!(pixel(&s, 300, 200)[3], 0.0, "outside the selection the layer stays empty"); + } + } + #[test] fn average_and_clouds() { let mut s = session(8); @@ -1714,8 +1740,12 @@ mod tests { s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok(); paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30)); + let steps = s.active().unwrap().history.past_len(); s.execute("image.revealAll", json!({})).unwrap(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30)); + let history = &s.active().unwrap().history; + assert_eq!(history.past_len(), steps + 1, "one step"); + assert_eq!(history.entries().last().map(String::as_str), Some("Reveal All"), "named as in Photoshop, not Crop"); assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false); } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..406bdbe499 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -105,7 +105,7 @@ pub fn list_images(dir: &str) -> Result> { let mut out: Vec = rd .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()).is_some_and(|x| OPENABLE.contains(&x.to_ascii_lowercase().as_str()))) + .filter(|p| p.is_file() && openable_name(&p.to_string_lossy())) .map(|p| p.to_string_lossy().into_owned()) .collect(); out.sort(); @@ -116,12 +116,20 @@ pub fn list_images(dir: &str) -> 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. +#[cfg(not(target_arch = "wasm32"))] +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)) +} + /// Extensions the batch commands pick up from a folder. #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ - "pdn", "ora", "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", "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", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone @@ -219,6 +227,45 @@ pub(crate) fn stem(path: &str) -> String { } } +/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on +/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them). +pub fn same_file(a: &str, b: &str) -> bool { + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) { + return x.dev() == y.dev() && x.ino() == y.ino(); + } + } + #[cfg(windows)] + { + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: NTFS and FAT compare names without case. + if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); + } + } + false +} + +/// A path as command results report it: `/` between components, whatever the platform. +/// +/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute` +/// carries the native separator, which would leave a reported path mixed. The control channel +/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so +/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep +/// theirs: Win32 does not accept `/` after a leading `\\`. +pub(crate) fn forward_slashes(path: String) -> String { + if path.starts_with(r"\\") { path } else { path.replace('\\', "/") } +} + pub(crate) fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() @@ -229,14 +276,56 @@ pub(crate) fn join(dir: &str, name: &str) -> String { } } -/// A file-name-safe version of a layer name. +/// A file-name-safe version of a layer name. A Windows device name (`nul`, `con.v2`) gets a `_` +/// before its first dot, so the file or folder is usable on Windows, wherever it was written. pub(crate) fn sanitize(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); let s = s.trim().trim_matches('.').to_string(); - if s.is_empty() { "layer".into() } else { s } + if s.is_empty() { "layer".into() } else { portable_device_name(s) } +} + +/// `name` with a `_` before its first dot when it is a Windows device name (`nul` → `nul_`, +/// `com1.v2` → `com1_.v2`); a `_` at the end would leave the device stem in place. +pub(crate) fn portable_device_name(name: String) -> String { + if !is_windows_device_name(&name) { + return name; + } + match name.split_once('.') { + Some((device, rest)) => format!("{device}_.{rest}"), + None => format!("{name}_"), + } +} + +/// Whether `component` names a Windows device (`CON`, `NUL`, `COM1`…, with any extension): +/// Windows opens the device instead of a file or folder of that name. `COM` and `LPT` take the +/// superscript digits ¹ ² ³ as well, as Windows does. +pub(crate) fn is_windows_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let port = |n: &str| matches!(n, "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(port) + || stem.strip_prefix("LPT").is_some_and(port) } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { + import_result(name, bytes).map(|r| r.document) +} + +/// [`import`] for a file that becomes a document of its own (Revert, Load Files into Stack; +/// Batch's scratch sessions use [`import_at_raw_ppi`]): a developed raw gets Preferences › Raw +/// Defaults › Resolution, as it does through File › Open. +pub(crate) fn import_opened(s: &Session, name: &str, bytes: &[u8]) -> Result { + import_at_raw_ppi(s.prefs().raw_defaults.resolution, name, bytes) +} + +fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Result { + let r = import_result(name, bytes)?; + let mut doc = r.document; + raw_resolution(raw_ppi, &r.warnings, &mut doc); + Ok(doc) +} + +fn import_result(name: &str, bytes: &[u8]) -> Result { crate::allocation::checkpoint("importing document")?; let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. @@ -246,15 +335,17 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } - Ok(r.document) + Ok(r) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD -/// previews and the PSD merged image. +/// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa +/// bits, DPX bits, DDS compression and mipmaps, PSD previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. pub quality: Option, + /// JPEG Options › Format Options (`"jpegFormat"`); unset, Baseline ("Standard"). + pub jpeg_format: Option, /// TIFF: keep the layers. Off unless a command's params ask (`"tiffLayers": true`). pub tiff_layers: bool, /// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or @@ -262,6 +353,11 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. pub dpx_bits: Option, + /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. + pub tiff_compression: Option, + /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, + /// `"ddsMipmaps"`); unset, uncompressed without mipmaps. + pub dds: photocraft_io::DdsOptions, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -271,22 +367,36 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { + quality: None, + jpeg_format: None, + tiff_layers: false, + tga_bits: None, + dpx_bits: None, + tiff_compression: None, + dds: photocraft_io::DdsOptions::default(), + image_previews: true, + psd_maximize_compatibility: true, + } } } impl SaveOpts { - /// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than - /// 24 or 32 is an error); the image previews and the PSD merged image from the session's File - /// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's - /// Save As checkbox and its Maximize Compatibility checkbox both start on). + /// `quality`, `jpegFormat`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a + /// `jpegFormat` it doesn't name or a `tgaBits` other than 24 or 32 is an error); the image + /// previews and the PSD merged image from the session's File Handling preferences (Ask When + /// Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox and its + /// Maximize Compatibility checkbox both start on). pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; Ok(SaveOpts { quality: f64_param(p, "quality"), + jpeg_format: jpeg_format_param(p, cmd)?, tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, + tiff_compression: tiff_compression_param(p, cmd)?, + dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -318,6 +428,40 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + use photocraft_codecs::TiffCompression; + match p.get("tiffCompression") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_str() { + Some("none") => Ok(Some(TiffCompression::None)), + Some("lzw") => Ok(Some(TiffCompression::Lzw)), + Some("zip") => Ok(Some(TiffCompression::Deflate)), + _ => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`tiffCompression` must be \"none\", \"lzw\" or \"zip\", got {v}") }), + }, + } +} + +/// The JPEG format in `p` (`"jpegFormat": "baseline" | "optimized" | "progressive"`; absent or +/// null: Baseline). Anything else is a bad parameter of command `cmd`. Shared like +/// [`tga_bits_param`]. +pub fn jpeg_format_param(p: &Value, cmd: &str) -> Result> { + match p.get("jpegFormat") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(photocraft_codecs::JpegFormat::parse).map(Some).ok_or_else(|| EngineError::BadParams { + cmd: cmd.into(), + msg: format!("`jpegFormat` must be \"baseline\", \"optimized\" or \"progressive\", got {v}"), + }), + } +} + +/// Photoshop's 0–12 JPEG quality as the encoder's 1–100 (0 → 1, 12 → 100, linear between; out of +/// range is clamped, and NaN or infinite is 100). +pub fn jpeg_quality(q: f64) -> u8 { + if q.is_finite() { (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8 } else { 100 } +} + /// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's /// depth). Anything else is a bad parameter of command `cmd`. pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result> { @@ -331,6 +475,30 @@ pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result Result> { + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let compression = match p.get("ddsCompression") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(photocraft_io::DdsCompression::from_name) + .ok_or_else(|| bad(format!("`ddsCompression` must be \"none\", \"bc1\", \"bc2\" or \"bc3\", got {v}")))?, + ), + }; + let mipmaps = match p.get("ddsMipmaps") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(format!("`ddsMipmaps` must be true or false, got {v}")))?), + }; + if compression.is_none() && mipmaps.is_none() { + return Ok(None); + } + Ok(Some(photocraft_io::DdsOptions { compression: compression.unwrap_or_default(), mipmaps: mipmaps.unwrap_or(false) })) +} + /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; @@ -340,19 +508,28 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, + // Linked files a PSD embeds are resolved against the document being worked on. + document_path: crate::doc_path::current(), ..Default::default() }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; + if let Some(c) = save.tiff_compression { + opts.encode.tiff_compression = c; + } + opts.encode.dds = save.dds; if let Some(q) = save.quality { - let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; + let q = jpeg_quality(q); opts.encode.jpeg_quality = q; // A quality on a WebP save asks for the lossy encoder; the default WebP stays lossless. opts.encode.webp_quality = q; opts.encode.webp_lossless = false; } + if let Some(f) = save.jpeg_format { + opts.encode.jpeg_format = f; + } photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -427,7 +604,7 @@ fn close_others(s: &mut Session, p: &Value) -> Result { fn revert(s: &mut Session) -> Result { let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?; let bytes = read_file(&path)?; - let fresh = import(&file_name(&path), &bytes)?; + let fresh = import_opened(s, &file_name(&path), &bytes)?; s.edit("Revert", |doc, active| { let (id, name) = (doc.id, doc.name.clone()); *doc = fresh; @@ -445,6 +622,15 @@ fn revert(s: &mut Session) -> Result { fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; + // A copy over the document's own file replaces it, and a flat or lossy format can't be + // undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the + // shell, after the OS has confirmed the replace. + if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) { + return Err(EngineError::BadParams { + cmd: "file.saveACopy".into(), + msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(), + }); + } let mut doc = (*d.doc).clone(); if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. @@ -461,7 +647,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { /// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1, /// linear) — the working space ACES render pipelines write. Only the io default is /// replaced; an embedded or EXIF-declared profile is kept untouched. -fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { +pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg { return; } @@ -471,6 +657,19 @@ fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { } } +/// Edit › Preferences › Raw Defaults › Resolution: the ppi a raw developed on open gets, as +/// Camera Raw's workflow options set it (the raw itself states none). `warnings` are the +/// import's notes, which say whether the raw was developed or only its preview opened. +pub fn raw_defaults(s: &Session, warnings: &[String], doc: &mut Document) { + raw_resolution(s.prefs().raw_defaults.resolution, warnings, doc); +} + +fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { + if ppi.is_finite() && warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) { + doc.resolution_dpi = ppi.clamp(1.0, 10_000.0) as f32; + } +} + pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { @@ -480,6 +679,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); + raw_defaults(s, &r.warnings, &mut doc); let path = match template_name(s, name) { Some(untitled) => { doc.name = untitled; @@ -521,8 +721,8 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option Result { let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" }; let path = str_param(p, "path", cmd)?.to_string(); let bytes = read_file(&path)?; - place_bytes(s, &path, bytes, linked.then(|| path.clone()), p) + // The link is stored absolute: a relative one would later be taken from the document's + // folder (`doc_path`), not the working directory the path was given in. + let link = linked.then(|| { + if std::path::Path::new(&path).is_relative() { + std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned()) + } else { + path.clone() + } + }); + place_bytes(s, &path, bytes, link, p) } // ---------- File Info (XMP) ---------- @@ -598,7 +807,42 @@ fn xml_escape(s: &str) -> String { } fn xml_unescape(s: &str) -> String { - s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let decoded = rest.as_bytes().iter().take(13).position(|&b| b == b';').and_then(|e| { + let c = match &rest[1..e] { + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + "amp" => '&', + n => { + let n = n.strip_prefix('#')?; + let code = match n.strip_prefix(['x', 'X']) { + Some(h) => u32::from_str_radix(h, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, e + 1)) + }); + match decoded { + Some((c, len)) => { + out.push(c); + rest = &rest[len..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out } /// The element `…` (first occurrence) as (start, end, inner). @@ -699,6 +943,18 @@ fn short_num(v: f64, decimals: usize) -> String { if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } +/// `v` to three significant digits, with no trailing zeros (0.031714 → "0.0317", 0.4 → "0.4"). +/// Two decimals threw away most of a slow shutter speed: a stored 0.031714 s printed as "0.03 s", +/// which is 5 % off, and a stored 0.0215 s printed as "0.02 s", 7 % off (#6958). +fn sig3(v: f64) -> String { + let magnitude = v.abs().log10().floor(); + // Clamped at both ends: a value at the large end must not ask for a negative field width, and + // at the small end the field is widened rather than truncated, so a positive exposure never + // reads as "0 s". 320 is beyond f64's decimal range, so it always terminates. + let decimals = if magnitude.is_finite() { (2.0 - magnitude).clamp(0.0, 320.0) } else { 0.0 }; + short_num(v, decimals as usize) +} + /// Exposure time as cameras print it: "1/250 s" for fractions of a second, "0.4 s" or "2 s" /// when the reciprocal isn't a whole number or the exposure is a second or longer. fn exposure_text(t: f64) -> Option { @@ -706,10 +962,18 @@ fn exposure_text(t: f64) -> Option { return None; } let r = 1.0 / t; - if t < 1.0 && (r - r.round()).abs() <= r * 0.01 { - return Some(format!("1/{} s", r.round())); + let n = r.round(); + // Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the shown time + // within 5 % of the stored one, and is the form a photographer expects ("1/32 s"). The test + // used to be relative to the reciprocal, so it shrank as the exposure slowed: under 1/50 s it + // fell below half a rounding step and nearly every fraction fell through to the decimal form + // (#6958). Above 1/10 s the fraction is only right when the reciprocal is very nearly whole + // (1/2 s, 1/4 s) — 0.4 s stays "0.4 s" rather than becoming "1/3 s". + let fraction = n.is_finite() && (1.0..=1e9).contains(&n) && (t < 0.1 || (t < 1.0 && n > 1.0 && (r - n).abs() <= r * 0.01)); + if fraction { + return Some(format!("1/{} s", n as u64)); } - Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 }))) + Some(format!("{} s", sig3(t))) } /// EXIF (`2024:02:17 12:40:19`) or XMP (`2024-02-17T12:40:19.00+01:00`) dates as @@ -867,8 +1131,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result { fn fit_image(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (w, h) = (d.doc.size.width as f64, d.doc.size.height as f64); - let bw = f64_param(p, "width").unwrap_or(w).max(1.0); - let bh = f64_param(p, "height").unwrap_or(h).max(1.0); + let bw = size_param(p, "width").unwrap_or(w); + let bh = size_param(p, "height").unwrap_or(h); let k = (bw / w).min(bh / h); if (k > 1.0 && p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(false)) || ((w * k).round() == w && (h * k).round() == h) { return Ok(json!({"width": w, "height": h, "changed": false})); @@ -903,11 +1167,14 @@ fn conditional_mode_change(s: &mut Session, p: &Value) -> Result { other => return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unsupported target mode \"{other}\" (rgb, grayscale, cmyk, lab)") }), }; let cur = mode_name(s.active().ok_or(EngineError::NoDocument)?.doc.mode); + // One spelling per source mode, for the array and the single-string forms alike (#7042). + let norm = |v: &str| { + let v = v.to_ascii_lowercase(); + if v == "gray" { "grayscale".to_string() } else { v } + }; let from: Vec = match p.get("from") { - Some(Value::Array(a)) => { - a.iter().filter_map(Value::as_str).map(|v| v.to_ascii_lowercase().replace("gray", "grayscale").replace("grayscalescale", "grayscale")).collect() - } - Some(Value::String(v)) if v != "any" => vec![v.to_ascii_lowercase()], + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(norm).collect(), + Some(Value::String(v)) => vec![norm(v)], _ => vec!["any".into()], }; if cur == to || !(from.iter().any(|f| f == "any" || f == cur)) { @@ -1033,23 +1300,72 @@ pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result Result<()>) -> Value { +pub(crate) fn process_files( + s: &Session, + inputs: &[String], + output: &str, + format: &str, + save: SaveOpts, + suffix: &str, + f: &dyn Fn(&mut Session) -> Result<()>, +) -> Value { let inputs: Vec = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); - process_tree(&inputs, output, format, save, suffix, f) + process_tree(s, &inputs, output, format, save, suffix, &|scratch, _| f(scratch).map(|()| false)) } /// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder -/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. -pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. `f` gets the +/// output path and returns `true` when it wrote the result there itself (Batch writes what a +/// recorded save marked); otherwise the active document is saved after it. +pub(crate) fn process_tree( + s: &Session, + inputs: &[BatchInput], + output: &str, + format: &str, + save: SaveOpts, + suffix: &str, + f: &dyn Fn(&mut Session, &str) -> Result, +) -> Value { let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { - let ext = - if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); - process_outputs(outputs, save, f) + process_outputs(s, outputs, save, f) } -fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +/// 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. +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"]; + 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 } +} + +#[cfg(test)] +#[test] +fn same_format_takes_the_file_names_extension() { + assert_eq!(same_format("/in/a.PNG"), "png"); + assert_eq!(same_format("/in/IMG_0001.ARW"), "psd"); + assert_eq!(same_format("/in/a.kra"), "psd"); + 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"); + // 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"); + assert_eq!(same_format("/in/.hidden"), "png"); +} + +fn process_outputs<'a>( + s: &Session, + outputs: impl IntoIterator, + save: SaveOpts, + f: &dyn Fn(&mut Session, &str) -> Result, +) -> Value { + // The scratch sessions have default preferences; a raw opens at the user's Raw Defaults. + let raw_ppi = s.prefs().raw_defaults.resolution; let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); @@ -1058,11 +1374,12 @@ fn process_outputs<'a>(outputs: impl IntoIterator, sav written.check(&out).map_err(EngineError::Other)?; let bytes = read_file(path)?; let mut scratch = Session::new(); - let doc = import(&file_name(path), &bytes)?; + let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; scratch.add_document(doc, Some(path.to_string())); - f(&mut scratch)?; - let d = scratch.active().ok_or(EngineError::NoDocument)?; - save_doc(&d.doc, &out, save)?; + if !f(&mut scratch, &out)? { + let d = scratch.active().ok_or(EngineError::NoDocument)?; + save_doc(&d.doc, &out, save)?; + } Ok(()) })(); match r { @@ -1097,15 +1414,26 @@ impl OutputClaims { } } +/// A recorded Save, Save As or Save a Copy, which marks the result Batch writes to its destination +/// (Photoshop's "Override Action 'Save As' Commands"). +pub(crate) fn batch_save_point(id: &str) -> bool { + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") +} + +/// Whether Batch and droplets keep a recorded step: save points mark the result, while other +/// shell-only saves and view steps (zoom, fit) don't change the output (#2752). +pub(crate) fn batch_keeps_step(id: &str) -> bool { + (batch_save_point(id) || !crate::actions_cmds::shell_save_command(id)) && !crate::actions_cmds::shell_view_command(id) +} + fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; let mut steps = parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; // Each result goes to the destination folder, which overrides the action's recorded Save / - // Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps - // (zoom, fit) don't change the output, so they are skipped (#2752). - steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id) && id != "file.export.quickExport"); - if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { + // Save As steps: they only mark the result. + steps.retain(|(id, _)| batch_keeps_step(id) && id != "file.export.quickExport"); + if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } let output = str_param(p, "output", cmd)?.to_string(); @@ -1128,20 +1456,38 @@ fn batch(s: &mut Session, p: &Value) -> Result { let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; - let run = |scratch: &mut Session| { + let authorize = s.authorize; + let run = move |scratch: &mut Session, out: &str| { + scratch.authorize = authorize; scratch.actions = actions.clone(); + // The document as the last recorded save found it, written once the action has run so a + // failing step still writes nothing. + let mut saved: Option> = None; for (id, params) in &steps { + if batch_save_point(id) { + saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + continue; + } let r = scratch.execute(id, params.clone())?; // A failed step inside a called action makes this file an error, not a saved result. if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { return Err(EngineError::Other(error)); } } - Ok(()) + // While the saved document is still open the steps after the save reach the result too + // (#3058), so it is saved after the action as usual. Closed after its save, or with another + // document left active, it is written as the save found it. + match saved { + Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { + save_doc(&doc, out, save)?; + Ok(true) + } + _ => Ok(false), + } }; let r = match names { - Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), - None => process_tree(&inputs, &output, &format, save, "", &run), + Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), + None => process_tree(s, &inputs, &output, &format, save, "", &run), }; Ok(r) } @@ -1156,14 +1502,14 @@ fn image_processor(s: &mut Session, p: &Value) -> Result { (w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})), }; let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false); - let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { + let r = process_tree(s, &inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch, _| { if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) { scratch.execute("image.mode.rgb", json!({}))?; } if let Some(fp) = &fit { fit_image(scratch, fp)?; } - Ok(()) + Ok(false) }); Ok(r) } @@ -1181,7 +1527,7 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { let mut docs = Vec::new(); for path in &paths { let bytes = read_file(path)?; - docs.push((file_name(path), import(&file_name(path), &bytes)?)); + docs.push((file_name(path), import_opened(s, &file_name(path), &bytes)?)); } let first = &docs[0].1; let w = docs.iter().map(|(_, d)| d.size.width).max().unwrap_or(1); @@ -1234,10 +1580,12 @@ fn has_live_effects(l: &Layer) -> bool { l.effects.enabled && l.effects.items.iter().any(|e| e.enabled()) } -/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask` -/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them -/// live on the baked raster does not mask the pixels twice. -fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface { +/// The layer of `doc` rendered alone (keeping what `keep` says) with the document's Global Light, +/// patterns and canvas, as a surface in the document's format. With `apply_mask` off the layer's +/// pixel and vector masks are left out of the render, so a caller that keeps them live on the +/// baked raster does not mask the pixels twice. +fn bake(doc: &Document, l: &Layer, keep_effects: bool, apply_mask: bool) -> Surface { + let canvas = doc.bounds(); let mut tmp = l.clone(); tmp.opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; @@ -1254,7 +1602,7 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mas // Render over the union of the canvas and the content so off-canvas pixels survive. let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)); let area = if keep_effects { area.inflate(256) } else { area }; - buffer_surface(&photocraft_compose::render_layer(&tmp, area), fmt) + buffer_surface(&photocraft_compose::render_layer_in(doc, &tmp, area), doc.pixel_format()) } fn walk_ids(doc: &Document) -> Vec { @@ -1264,8 +1612,6 @@ fn walk_ids(doc: &Document) -> Vec { fn flatten_all_effects(s: &mut Session) -> Result { let mut n = 0; s.edit("Flatten All Layer Effects", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { @@ -1273,7 +1619,7 @@ fn flatten_all_effects(s: &mut Session) -> Result { } // The pixel and vector masks stay on the layer (baking must not destroy them), so the // pixels are baked without them and each mask then applies once to the baked raster. - let px = bake(l, canvas, fmt, true, false); + let px = bake(doc, l, true, false); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.effects = Default::default(); @@ -1290,8 +1636,6 @@ fn flatten_all_effects(s: &mut Session) -> Result { fn flatten_all_masks(s: &mut Session) -> Result { let (mut n, mut skipped) = (0, 0); s.edit("Flatten All Masks", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; let masked = l.mask.as_ref().is_some_and(|m| m.enabled) || l.vector_mask.is_some(); @@ -1304,7 +1648,7 @@ fn flatten_all_masks(s: &mut Session) -> Result { skipped += 1; continue; } - let px = bake(l, canvas, fmt, false, true); + let px = bake(doc, l, false, true); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.mask = None; @@ -1432,7 +1776,7 @@ fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String { lattice.layers.push(a); } let out = photocraft_compose::flatten(&lattice); - let mut s = format!("TITLE \"{}\"\n# Created by Photocraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); + let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); // .cube order: red changes fastest, then green, then blue. for b in 0..n { for g in 0..n { @@ -1594,7 +1938,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, native_doc, save_a_copy ), @@ -1657,7 +2001,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1666,7 +2010,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1694,7 +2038,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, native_doc, layers_to_files ), @@ -1761,4 +2105,38 @@ mod aces_exr_tests { let d = &s.active().unwrap().doc; assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice())); } + + /// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent): + /// the ACEScg preference applies there too, not only to `file.openAs`. + #[test] + fn exr_aces_cg_preference_applies_to_background_opens() { + use crate::jobs::{OpenSource, Started}; + let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap(); + let bytes = + std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap()); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = Session::new(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() { + Started::Job(id) => s.wait_job(id).unwrap(), + Started::Done(v) => v, + }; + // ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which + // names the ACEScg profile; linear sRGB (io's HDR default) opens without asking. + let profile = match v.get("held").and_then(Value::as_u64) { + Some(held) => { + assert!(on, "{v}"); + assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}"); + s.take_held_open(held).unwrap().0.icc_profile + } + None => { + assert!(v.get("document").is_some(), "{v}"); + s.active().unwrap().doc.icc_profile.clone() + } + }; + assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + } } diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs index e5dcc4a6e6..8e14bcf428 100644 --- a/crates/engine/src/file_cmds/batch_naming.rs +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -87,8 +87,7 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V if base.is_empty() || base.ends_with(['.', ' ']) { return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space")); } - let ext = - if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; let name = format!("{base}.{ext}"); if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..3115d713b6 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -65,6 +65,88 @@ fn camera_raw_opens_as_16_bit_with_notes() { assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err()); } +/// #5799: Batch with Format "Same" over a folder of camera raws saved nothing (every output was +/// a raw PhotoCraft can't write). Raws are saved as PSD; other inputs keep their format. +#[test] +fn batch_same_format_saves_camera_raws_as_psd() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-same-raw"); + let (w, h) = (24, 16); + std::fs::write(join(&dir, "IMG_0001.dng"), DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + png(&dir, "b.png", 8, 8, "#ff0000"); + // Also with a file naming template, which picks the output extension separately. + for (out, naming) in [("out", None), ("named", Some("{name}_{serial}"))] { + let out = join(&dir, out); + let mut p = json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"}); + if let Some(naming) = naming { + p["naming"] = json!(naming); + } + let r = Session::new().execute("file.automate.batch", p).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{naming:?}: {r}"); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + files.sort(); + assert_eq!(files.len(), 2, "{r}"); + assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}"); + let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document; + assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32)); + } +} + +/// Edit › Preferences › Raw Defaults › Resolution sets the ppi a raw opens with, through +/// File › Open (the background open job) and through opening bytes; other files keep their own. +#[test] +fn a_developed_raw_opens_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (w, h) = (24, 16); + let raw = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + let dir = tmp("raw-defaults"); + let path = join(&dir, "IMG_0002.dng"); + std::fs::write(&path, &raw).unwrap(); + let open = |s: &mut Session, path: &str| match s.start_open(path, crate::jobs::OpenSource::Path(path.into())).unwrap() { + crate::jobs::Started::Job(id) => { + s.wait_job(id).unwrap(); + } + crate::jobs::Started::Done(_) => {} + }; + let mut s = Session::new(); + open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 300.0, "the default"); + s.execute("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); + open_bytes_as(&mut s, "IMG_0001.dng", &raw, None, None).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0); + open(&mut s, &path); + assert_eq!(doc(&s).resolution_dpi, 240.0, "File › Open"); + let png = png(&dir, "a.png", 4, 4, "#ff0000"); + open(&mut s, &png); + assert_eq!(doc(&s).resolution_dpi, 72.0, "not a raw"); +} + +/// The other ways a raw becomes a document of its own take Raw Defaults › Resolution too: +/// File › Revert, Batch and Load Files into Stack (all of them opened a raw at 72 ppi). +#[test] +fn revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (w, h) = (24, 16); + let dir = tmp("raw-defaults-siblings"); + let path = join(&dir, "IMG_0003.dng"); + std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + let mut s = Session::new(); + s.execute("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); + open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap(); + s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 150.0); + s.execute("file.revert", json!({})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0, "File › Revert"); + let out = join(&dir, "out"); + let r = s.execute("file.automate.batch", json!({"steps": [], "input": [path.clone()], "output": out, "format": "psd"})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + let psd = photocraft_io::import("IMG_0003.psd", &std::fs::read(files[0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(psd.resolution_dpi, 240.0, "Batch"); + s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [path]})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0, "Load Files into Stack"); +} + #[test] fn revert_reloads_as_one_undoable_step() { let dir = tmp("revert"); @@ -135,6 +217,44 @@ fn save_a_copy_keeps_path_and_dirty_state() { assert!(s.execute("file.saveACopy", json!({})).is_err()); } +/// A copy over the document's own file, however the path is spelt, would replace it (a lossy +/// re-encode can't be undone), so it is refused unless `overwrite` is passed (#7437). +#[test] +fn save_a_copy_refuses_the_documents_own_file() { + let dir = tmp("copy-own"); + let path = png(&dir, "own.png", 6, 4, "#ff0000"); + let bytes = std::fs::read(&path).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let link = join(&dir, "link.png"); + std::fs::hard_link(&path, &link).unwrap(); + let rev = s.active().unwrap().revision; + for p in [path.clone(), format!("{dir}/./own.png"), link] { + let r = s.execute("file.saveACopy", json!({"path": p})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("overwrite")), "{p}: {r:?}"); + assert!(s.execute("file.saveACopy", json!({"path": p, "overwrite": false})).is_err(), "{p}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{p}"); + let st = s.active().unwrap(); + assert_eq!(st.path.as_deref(), Some(path.as_str()), "{p}"); + assert_eq!(st.revision, rev, "{p}"); + assert!(st.is_dirty(), "{p}"); + } + let other = join(&dir, "other.png"); + s.execute("file.saveACopy", json!({"path": other})).unwrap(); + assert!(std::fs::metadata(&other).unwrap().len() > 0); + s.execute("file.saveACopy", json!({"path": path, "overwrite": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(s.active().unwrap().path.as_deref(), Some(path.as_str())); + assert!(s.active().unwrap().is_dirty()); + + // A document that was never saved has no file of its own: it copies anywhere, as before. + let mut fresh = session(4, 4, 8); + fresh.execute("file.saveACopy", json!({"path": path})).unwrap(); + assert_eq!(fresh.active().unwrap().path, None); +} + /// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an /// alpha channel is written as 32-bit, with the channel as the alpha. #[test] @@ -181,6 +301,53 @@ fn save_a_copy_writes_the_dpx_bits_asked_for() { } } +/// `ddsCompression` picks the DDS FourCC (or none, uncompressed) and `ddsMipmaps` the mip count +/// in the header; unset, an uncompressed texture without mipmaps. Bad values are refused before +/// anything is written. +#[test] +fn save_a_copy_writes_the_dds_options_asked_for() { + let dir = tmp("dds-options"); + let le32 = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()); + for (params, fourcc, mips) in [ + (json!({}), [0; 4], 1), + (json!({"ddsCompression": "none", "ddsMipmaps": true}), [0; 4], 5), + (json!({"ddsCompression": "bc1"}), *b"DXT1", 1), + (json!({"ddsCompression": "DXT3", "ddsMipmaps": false}), *b"DXT3", 1), + (json!({"ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5), + (json!({"ddsCompression": null, "ddsMipmaps": null}), [0; 4], 1), + ] { + let mut s = session(16, 12, 8); + let out = join(&dir, "a.dds"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], &bytes[84..88], le32(&bytes, 28)), (&b"DDS "[..], &fourcc[..], mips), "{params}"); + assert_eq!(photocraft_io::import("a.dds", &bytes).unwrap().document.size, photocraft_geom::Size::new(16, 12), "{params}"); + } + let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsCompression": 1}), json!({"ddsMipmaps": "yes"}), json!({"ddsMipmaps": 1})] { + let out = join(&dir, "bad.dds"); + let mut p = bad.clone(); + p["path"] = json!(out); + let r = s.execute("file.saveACopy", p); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is untouched"); + } +} + +#[test] +fn dds_params_are_none_until_one_is_given() { + use photocraft_io::{DdsCompression, DdsOptions}; + assert_eq!(dds_param(&json!({}), "file.saveAs").unwrap(), None); + assert_eq!(dds_param(&json!({"ddsCompression": null}), "file.saveAs").unwrap(), None); + assert_eq!(dds_param(&json!({"ddsMipmaps": true}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::None, mipmaps: true })); + assert_eq!(dds_param(&json!({"ddsCompression": "bc2"}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::Bc2, mipmaps: false })); +} + /// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written. #[test] fn bad_targa_bits_are_refused_before_writing() { @@ -197,6 +364,57 @@ fn bad_targa_bits_are_refused_before_writing() { } } +/// `jpegFormat` picks JPEG Options' format: Progressive writes a progressive frame (SOF2), the +/// others a baseline one (SOF0); `quality` keeps Photoshop's 0–12 scale. +#[test] +fn save_a_copy_writes_the_jpeg_format_asked_for() { + let dir = tmp("jpeg-format"); + let mut s = session(40, 30, 8); + let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for (params, progressive) in [ + (json!({}), false), + (json!({"jpegFormat": null}), false), + (json!({"jpegFormat": "baseline", "quality": 12}), false), + (json!({"jpegFormat": "optimized", "quality": 10}), false), + (json!({"jpegFormat": "progressive", "quality": 3}), true), + ] { + let out = join(&dir, "a.jpg"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(marker(&bytes, 0xC2), progressive, "{params}"); + assert_eq!(marker(&bytes, 0xC0), !progressive, "{params}"); + assert_eq!(photocraft_io::import("a.jpg", &bytes).unwrap().document.size.width, 40, "{params}"); + } + // Layers to Files takes it too. + s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "jpg", "jpegFormat": "progressive"})).unwrap(); + let layers: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| !p.ends_with("a.jpg")).collect(); + assert!(!layers.is_empty()); + for p in layers { + assert!(marker(&std::fs::read(&p).unwrap(), 0xC2), "{p:?} is progressive"); + } + assert_eq!((jpeg_quality(0.0), jpeg_quality(12.0), jpeg_quality(6.0)), (1, 100, 51)); + assert_eq!((jpeg_quality(-3.0), jpeg_quality(40.0), jpeg_quality(f64::NAN), jpeg_quality(f64::NEG_INFINITY)), (1, 100, 100, 100)); +} + +/// A `jpegFormat` other than the three names is a bad parameter, reported before anything is +/// written. +#[test] +fn bad_jpeg_formats_are_refused_before_writing() { + let dir = tmp("jpeg-bad"); + let mut s = session(8, 8, 8); + for bad in [json!("Progressive"), json!(""), json!(2), json!(true), json!(["baseline"]), json!({"format": "baseline"})] { + let out = join(&dir, "a.jpg"); + let r = s.execute("file.saveACopy", json!({"path": out, "jpegFormat": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "jpg", "jpegFormat": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "{bad}: no layer written"); + } +} + /// Preferences › File Handling › Image Previews decides whether a PSD gets Photoshop's /// thumbnail resource (1036): Always and Ask When Saving write it, Never doesn't. #[test] @@ -456,6 +674,54 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() { assert_eq!(camera_data(Some(b"II*\0\xff\xff\xff\xff"), Some("0/0 exif:ExposureTime=\"-1/0\"")), json!({})); } +/// A slow shutter speed whose reciprocal is not whole prints as the nearest fraction a camera +/// would write, not as two decimals. The old 1 %-of-the-reciprocal test shrank as the exposure +/// slowed, so below 1/50 s it fell below half a rounding step and nearly every such fraction fell +/// through to the decimal form (#6958). +#[test] +fn camera_data_prints_a_slow_exposure_as_the_fraction_a_camera_writes() { + let text = |t: f64| exposure_text(t).expect("an exposure"); + // The two values from the report: 31714/1000000 s and 215/10000 s. + assert_eq!(text(0.031_714), "1/32 s", "a stored 0.031714 s is about 1/32 s"); + assert_eq!(text(0.0215), "1/47 s", "a stored 0.0215 s is about 1/47 s"); + // The rest of a slow shutter scale, and the exact reciprocals that already worked. + assert_eq!(text(0.125), "1/8 s"); + assert_eq!(text(1.0 / 60.0), "1/60 s"); + assert_eq!(text(1.0 / 4000.0), "1/4000 s"); + assert_eq!(text(0.04), "1/25 s"); + assert_eq!(text(0.09), "1/11 s"); + // Every shown time stays within 5 % of the stored one — the point of the report. + for t in [0.031_714, 0.0215, 0.04, 0.09, 0.125, 1.0 / 60.0, 1.0 / 4000.0] { + let n: f64 = text(t).split('/').nth(1).and_then(|d| d.split(' ').next()).expect("1/n s").parse().expect("a number"); + assert!((1.0 / n / t - 1.0).abs() <= 0.05, "{t} shown as {} is more than 5 % off", text(t)); + } + // A tenth of a second and longer keeps the Photoshop forms: a near-whole reciprocal is a + // fraction, anything else is the decimal at three significant digits. + assert_eq!(text(0.5), "1/2 s"); + assert_eq!(text(0.25), "1/4 s"); + assert_eq!(text(0.4), "0.4 s"); + assert_eq!(text(0.105), "0.105 s", "two decimals would read 0.11 s"); + assert_eq!(text(1.25), "1.25 s", "one decimal would read 1.3 s"); + assert_eq!(text(2.0), "2 s"); + // A hostile value cannot panic, and a positive exposure never reads as "0 s" (never-crash, rule 9). + assert_ne!(exposure_text(f64::MIN_POSITIVE).as_deref(), Some("0 s")); + assert_ne!(exposure_text(f64::MIN_POSITIVE), None); + assert_eq!(exposure_text(0.0), None); + assert_eq!(exposure_text(-1.0), None); + assert_eq!(exposure_text(f64::NAN), None); + assert_eq!(exposure_text(f64::INFINITY), None); + assert_ne!(exposure_text(f64::MAX), None); + // The same through a real EXIF block, so the round trip to File Info is covered too. + let camera = |t: f64| { + let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { exposure_time: Some(t), ..Default::default() }); + camera_data(Some(&exif), None)["exposure"].as_str().map(str::to_owned) + }; + assert_eq!(camera(0.031_714), Some("1/32 s".to_owned())); + assert_eq!(camera(0.0215), Some("1/47 s".to_owned())); + // An absurd exposure is capped to one dialog line, as every other camera field is. + assert!(camera(f64::MAX).expect("an exposure").len() <= 200, "a huge exposure must stay on one dialog line"); +} + const MULTILINGUAL_XMP: &str = r#" Result<()> { @@ -709,6 +1012,74 @@ fn flatten_all_layer_effects_keeps_masks() { } } +/// Flattens all layer effects on a red square carrying `fx` (in a document prepared by `setup`) +/// and asserts the composite did not change: the bake uses the document's own context (#5719). +fn assert_flatten_effects_is_a_no_op(depth: u32, setup: impl FnOnce(&mut Document), fx: photocraft_doc::Effect) { + let mut s = session(40, 40, depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 14, "y": 14, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + setup(doc); + let l = doc.layer_mut(id).unwrap(); + l.effects.enabled = true; + l.effects.items.push(fx); + Ok(()) + }) + .unwrap(); + let before = photocraft_compose::flatten(doc(&s)); + assert_eq!(s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap()["flattened"], 1); + let after = photocraft_compose::flatten(doc(&s)); + for (i, (a, b)) in after.px.iter().zip(&before.px).enumerate() { + for k in 0..4 { + assert!((a[k] - b[k]).abs() < 0.02, "depth {depth}: pixel ({}, {}) changed: {b:?} -> {a:?}", i % 40, i / 40); + } + } +} + +/// A Drop Shadow that uses Global Light is baked at the document's angle, not the default 120°. +#[test] +fn flatten_all_layer_effects_uses_the_documents_global_light() { + for depth in [8, 16, 32] { + let shadow = photocraft_doc::Effect::DropShadow(photocraft_doc::effects::Shadow { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 0.0, 1.0), + angle: 120.0, + use_global_light: true, + distance: 10.0, + spread: 0.0, + size: 0.0, + contour: photocraft_doc::effects::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }); + assert_flatten_effects_is_a_no_op(depth, |d| d.global_light.angle = 0.0, shadow); + } +} + +/// A Pattern Overlay of a document pattern survives the bake instead of vanishing. +#[test] +fn flatten_all_layer_effects_keeps_a_pattern_overlay() { + for depth in [8, 16, 32] { + let mut tile = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Rgb, photocraft_color::SampleType::U8, true)); + tile.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 1.0, 0.0, 1.0]); + let pat = photocraft_doc::Pattern::new("green", tile, 2, 2); + let overlay = photocraft_doc::Effect::PatternOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + assert_flatten_effects_is_a_no_op(depth, |d| d.patterns.push(pat), overlay); + } +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); @@ -1033,7 +1404,7 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { let path = "/tmp/pics/a.psd"; s.active_mut().unwrap().path = Some(path.into()); let r = s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); - let (program, args) = crate::layer_menu_cmds::reveal_command(path); + let (program, args) = crate::layer_menu_cmds::reveal_command(path).unwrap(); assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); // The tab menu names a background tab: the active document is not the one revealed. @@ -1248,3 +1619,155 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { assert_eq!(std::fs::read(&first).unwrap(), before[0]); assert_eq!(std::fs::read(&second).unwrap(), before[1]); } + +/// Batch takes the result at the action's recorded Save, Save As or Save a Copy, as Photoshop's +/// "Override Action 'Save As' Commands" does: a document closed after its save is written as the +/// save found it. The usual "…, Save As, Close" ending failed on every file: the Save was dropped, +/// and nothing was left open to save after the action. +#[test] +fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() { + let dir = tmp("batch-save-close"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let saved = |out: &str| photocraft_io::import("red.png", &std::fs::read(join(out, "red.png")).unwrap()).unwrap().document; + let mut s = Session::new(); + let out = join(&dir, "close"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"], json!([join(&out, "red.png")])); + assert_eq!(photocraft_compose::render(&saved(&out), Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red"); + + // Save a Copy marks the result the same way, and its recorded path is never written. + let out = join(&dir, "copy"); + let elsewhere = join(&dir, "elsewhere.png"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {"path": elsewhere}], ["file.close", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(photocraft_compose::render(&saved(&out), Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0]); + assert!(!std::path::Path::new(&elsewhere).exists()); + + // With no Save step the result is still saved after the action; with nothing left open then, + // the file is an error, not a silent skip. + let out = join(&dir, "closed"); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": [["file.close", {}]]})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}"); +} + +/// The document a recorded save marked is the result, not the one left active after it closed. +/// Here the action saves and closes a second document it made, then inverts the input; the output +/// used to be the inverted input, written in place of the result without an error. +#[test] +fn batch_saves_the_document_active_at_the_save_not_the_one_left_after_it() { + let dir = tmp("batch-save-second"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let out = join(&dir, "out"); + let steps = json!([ + ["file.new", {"width": 4, "height": 4, "background": "#00ff00"}], + ["file.save", {}], + ["file.close", {}], + ["image.adjustments.invert", {}] + ]); + let r = Session::new().execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let saved = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!((saved.size.width, saved.size.height), (4, 4)); + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 0.0, 1.0]); +} + +/// While the saved document is open, the steps after its save still reach the result (#3058: a +/// recorded Save then Rotate writes the rotated image). A step failing after the save writes +/// nothing, as when Batch saved only after the action. +#[test] +fn batch_applies_the_steps_after_a_save_while_its_document_is_open() { + let dir = tmp("batch-save-then-edit"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let mut s = Session::new(); + let out = join(&dir, "edited"); + let steps = json!([["file.saveAs", {}], ["image.adjustments.invert", {}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let saved = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0]); + + let out = join(&dir, "failed"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["layer.select", {"layer": "missing"}]]); + let r = s.execute("file.automate.batch", json!({"input": [red], "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}"); + assert!(!std::path::Path::new(&join(&out, "red.png")).exists(), "{r}"); +} + +#[test] +fn exposure_text_near_one_second_is_not_one_over_one() { + assert_eq!(exposure_text(0.996).as_deref(), Some("0.996 s"), "not \"1/1 s\"; three significant digits"); + assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s")); + assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s")); +} + +/// A folder batch picks up GIMP's gzipped `.xcf.gz`, whose last extension alone (`gz`) says nothing. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() { + assert!(super::openable_name("/scans/page 01.xcf.gz")); + assert!(super::openable_name("C:\\scans\\PAGE.XCF.GZ")); + assert!(super::openable_name("page.xcfgz")); + assert!(super::openable_name("page.PNG")); + assert!(!super::openable_name("archive.tar.gz")); + assert!(!super::openable_name("notes.txt")); +} + +/// The Compression tag (259) of a TIFF's first directory. +fn tiff_compression_tag(b: &[u8]) -> Option { + let le = b.get(..2)? == b"II"; + let u16_at = |i: usize| b.get(i..i + 2).map(|s| if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }); + let u32_at = + |i: usize| b.get(i..i + 4).map(|s| if le { u32::from_le_bytes([s[0], s[1], s[2], s[3]]) } else { u32::from_be_bytes([s[0], s[1], s[2], s[3]]) }); + let ifd = u32_at(4)? as usize; + (0..usize::from(u16_at(ifd)?)).map(|k| ifd + 2 + 12 * k).find(|e| u16_at(*e) == Some(259)).and_then(|e| u16_at(e + 8)) +} + +/// #5301: `tiffCompression` picks a TIFF's compression (TIFF Options: None, LZW, ZIP); unset, +/// none. Anything else is refused before anything is written. +#[test] +fn save_a_copy_writes_the_tiff_compression_asked_for() { + let dir = tmp("tiff-compression"); + for (params, tag) in + [(json!({}), 1), (json!({"tiffCompression": "none"}), 1), (json!({"tiffCompression": "lzw"}), 5), (json!({"tiffCompression": "zip"}), 8)] + { + let mut s = session(12, 6, 8); + let out = join(&dir, "a.tif"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(tiff_compression_tag(&bytes), Some(tag), "{params}"); + assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); + } +} + +#[test] +fn sanitize_keeps_windows_device_names_off_the_disk() { + assert_eq!(sanitize("nul"), "nul_"); + assert_eq!(sanitize("CON"), "CON_"); + assert_eq!(sanitize("com1.v2"), "com1_.v2"); + assert_eq!(sanitize("lpt9 .final"), "lpt9 _.final"); + assert_eq!(sanitize("COM¹"), "COM¹_"); + assert_eq!(sanitize("LPT².txt"), "LPT²_.txt"); + for name in ["nul", "CON", "com1.v2", "lpt9 .final", "aux", "prn", "conin$", "clock$", "COM¹", "lpt³.x"] { + assert!(!is_windows_device_name(&sanitize(name)), "{name} -> {}", sanitize(name)); + } + // Names that only start like a device are files like any other. + for name in ["console", "normal", "com10", "lpt0", "nullify", "con_", "com⁴", "com¹¹"] { + assert_eq!(sanitize(name), name); + } +} diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 2aa0a02bdc..574241681a 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -106,13 +106,13 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let source = match contents { "foreground" => color_source(s, s.tools.foreground), "background" => color_source(s, s.tools.background), - "color" => color_source(s, color_param(p, "color", s.tools.foreground)), + "color" => color_source(s, color_param(CMD, p, "color", s.tools.foreground)?), "black" => Source::Color([0.0, 0.0, 0.0, 1.0]), "gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]), "white" => Source::Color([1.0; 4]), "pattern" => { let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; - let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); + let (scale, angle, _, phase) = crate::pattern_cmds::placement(CMD, p)?; let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?; // Photoshop's Fill uses the canvas origin. let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle); @@ -120,17 +120,8 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { Source::Pixels(area, photocraft_compose::pattern::render(&tile, &place, area)) } "history" => { - let explicit = match p.get("state") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().ok_or_else(|| bad(format!("`state` must be a history state index, not {v}")))? as usize), - }; - let past = st.history.past_len(); - let source_doc = match explicit { - Some(i) if i == past => st.doc.clone(), - Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?, - // The History Brush's default source: the oldest state still held. - None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), - }; + // A History panel row `state` or a `snapshot`, else the History Brush's source. + let source_doc = crate::history_source_cmds::paint_source(st, p, CMD)?; let src = crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p) .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?; Source::Pixels(area, read_rgba(src, area, "reading Fill's history source")?) @@ -334,30 +325,6 @@ pub(crate) fn read_window(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool if cancelled() { None } else { Some(out) } } -/// Which pixels of `r` a selection (or mask) covers at all: channel 0 above zero, read in -/// parallel bands as [`read_window`] does, without holding the coverage itself. -pub(crate) fn read_window_cover(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { - use rayon::prelude::*; - const BAND: usize = 64; - let n = surf.channels().max(1); - let (w, h) = (r.width() as usize, r.height() as usize); - let mut out = vec![false; w * h]; - if out.is_empty() { - return Some(out); - } - out.par_chunks_mut(BAND * w).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| { - if cancelled() { - return; - } - let y0 = r.y0 + (i * BAND) as i32; - surf.read_region_into(Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / w) as i32), buf); - for (o, px) in chunk.iter_mut().zip(buf.chunks_exact(n)) { - *o = px[0] > 0.0; - } - }); - if cancelled() { None } else { Some(out) } -} - /// Channel 0 of [`read_window`] (a selection's or an alpha channel's coverage). pub(crate) fn read_window_mask(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { use rayon::prelude::*; @@ -518,8 +485,17 @@ fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rec continue; } } - let mut o = if restore && blend == BlendMode::Normal { lerp(d, c, k) } else { photocraft_color::blend::composite(blend, d, c, k) }; - if keep_alpha || !fmt.alpha { + let keep = keep_alpha || !fmt.alpha; + let mut o = if restore && blend == BlendMode::Normal { + lerp(d, c, k) + } else if keep { + // Alpha kept: source atop, so a half-transparent pixel takes the new colour by the + // source alpha alone (#5783). + photocraft_color::blend::composite_atop(blend, d, c, k) + } else { + photocraft_color::blend::composite(blend, d, c, k) + }; + if keep { o[3] = d[3]; } let m = from_rgba_into(fmt, o, &mut enc); @@ -616,6 +592,21 @@ mod tests { })); } + #[test] + fn preserve_transparency_blends_source_atop_on_semi_transparent_pixels() { + // White at 50%, then black at 50% keeping alpha: Photoshop gives (127,127,127,128), the + // colour moved halfway, at every depth and in non-Normal modes too (#5783). + for depth in [8, 16, 32] { + for mode in ["normal", "multiply"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap(); + s.execute(CMD, json!({"contents": "white", "opacity": 50})).unwrap(); + s.execute(CMD, json!({"contents": "black", "opacity": 50, "mode": mode, "preserveTransparency": true})).unwrap(); + assert!(close(px(&s, 3, 3), [0.5, 0.5, 0.5, 0.5]), "{depth} {mode} {:?}", px(&s, 3, 3)); + } + } + } + #[test] fn history_pattern_and_content_aware() { let mut s = session(); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index e40be7b6ac..b115a9f5e0 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -234,7 +234,10 @@ pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), Strin pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result { validate_params(id, p)?; // Session state some filters read (colours) becomes explicit params; external inputs resolve here. - let prepared = crate::filters_ext::prepare(s, id, p); + let mut prepared = crate::filters_ext::prepare(s, id, p); + if id == "filter.distort.displace" { + prepared = crate::filters_ext::displace_request(&prepared); + } let p = &prepared; let mut fp = params_for(id, p).ok_or_else(|| EngineError::Other(format!("unknown filter {id}")))?; crate::filters_ext::resolve(s, &mut fp, p)?; @@ -262,9 +265,10 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result }; let selection = doc.selection.clone(); let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds); - // Distortions centre on the selection when there is one, else the canvas. + // Distortions centre on the selection when there is one, else the canvas. Clouds and + // Fibers place and size their pattern by the canvas either way; the selection masks them. let doc_bounds = doc.bounds(); - let bounds = sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds); + let bounds = if fp.sizes_by_canvas() { doc_bounds } else { sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds) }; // Neighbourhood filters repeat edge pixels at the canvas edge (or past it, where the // layer has off-canvas pixels) instead of fading in transparency, as Photoshop does. if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? { @@ -292,9 +296,29 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let surf = match &mut l.content { LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { + // A smart filter re-renders from its params alone, without the document's + // channels, so Lens Blur's alpha-channel depth would be lost (#7400). + if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) { + return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into())); + } + // A smart filter re-renders from its params alone: a Displace map read from an + // open document, which the re-render can't read again, is kept in them, as + // Photoshop embeds it (#7423). A `mapPath` stays a path, read next to the + // document on each re-render. + let params = match &fp { + FilterParams::Displace { map: Some(map), .. } + if photocraft_io::smart_map::displace_embedded_map(¶ms).is_none() && crate::filters_ext::map_path(¶ms).is_none() => + { + photocraft_io::smart_map::displace_embed_map(¶ms, map).ok_or_else(|| { + EngineError::Other( + "Displace: the displacement map is too large to keep in a Smart Object's filter: rasterize the layer first".into(), + ) + })? + } + _ => params.clone(), + }; // Non-destructive: record the filter and re-render the smart object from its source. - let sf = - SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + let sf = SmartFilter { command: id.clone(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()).map(|()| fp); } _ => return Err(EngineError::Other("not a pixel layer".into())), @@ -304,7 +328,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let area = algo::output_area(&fp, content, bounds, sel_bounds); *surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?; if let Some(before) = &before { - keep_alpha(before, surf); + keep_alpha(before, surf, &|| ctx.cancelled())?; } Ok(fp) }, @@ -313,25 +337,56 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } /// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed. -fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { +/// +/// The tiles are independent, so they are fixed in parallel, each in a scratch surface of its +/// own, and `cancelled` is asked before each one: on a 24 MP layer (a Background, whose +/// transparency is locked) a serial pass took about 400 ms that a cancelled job had to sit out +/// (#6205). `Err(Cancelled)` leaves `new` holding all its tiles, some of them not yet fixed. +pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface, cancelled: &(dyn Fn() -> bool + Sync)) -> Result<()> { + use photocraft_raster::Surface; + use rayon::prelude::*; if old.format() != new.format() || !new.format().alpha { - return; + return Ok(()); } let mut coords: Vec<_> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect(); coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c)); - let n = new.channels(); - for c in coords { - let r = c.rect(); - let o = old.read_region(r); - let mut v = new.read_region(r); - for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { - if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { - *a = *b; + let (fmt, n) = (new.format(), new.channels()); + let default = new.default_pixel(); + // Each changed tile alone in a surface (a tile `new` lacks is taken as its default pixel, as + // `write_region` would create it). + let mut tiles: Vec<(photocraft_geom::Rect, Surface)> = coords + .iter() + .map(|c| { + let mut s = Surface::with_default(fmt, &default); + s.put_tiles(new.take_tiles(c.rect())); + (c.rect(), s) + }) + .collect(); + let fixed = tiles + .par_iter_mut() + .map(|(r, s)| { + if cancelled() { + return false; } - } - new.write_region(r, &v); + let o = old.read_region(*r); + let mut v = s.read_region(*r); + for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { + if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { + *a = *b; + } + } + s.write_region(*r, &v); + true + }) + .reduce(|| true, |a, b| a && b); + for (_, s) in tiles { + new.put_tiles(s.tiles().map(|(c, t)| (*c, t.clone()))); + } + if !fixed { + return Err(EngineError::Cancelled); } new.prune(); + Ok(()) } macro_rules! filter_cmd { @@ -828,4 +883,135 @@ mod tests { assert_eq!(sm.smart_filters[0].params["radius"], json!(2)); assert!(sm.cache.as_ref().unwrap().pixel(10, 5)[3] > 0.0, "cache was blurred"); } + + /// The serial `keep_alpha` before #6205, kept as the oracle for the parallel one. + fn keep_alpha_serial(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { + if old.format() != new.format() || !new.format().alpha { + return; + } + let mut coords: Vec<_> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect(); + coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c)); + let n = new.channels(); + for c in coords { + let r = c.rect(); + let o = old.read_region(r); + let mut v = new.read_region(r); + for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) { + if let (Some(a), Some(b)) = (pn.last_mut(), po.last()) { + *a = *b; + } + } + new.write_region(r, &v); + } + new.prune(); + } + + /// A layer before and after a filter: tiles the filter changed, one it left shared, one it + /// emptied (only in `old`) and one it added (only in `new`). + fn alpha_pair(sample: photocraft_color::SampleType) -> (photocraft_raster::Surface, photocraft_raster::Surface) { + use photocraft_geom::Rect; + use photocraft_raster::Surface; + let fmt = photocraft_color::PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let pattern = |r: Rect, k: f32| -> Vec { + (0..r.width() as usize * r.height() as usize * 4).map(|i| ((i as f32 * 0.37 + k).sin() * 0.5 + 0.5).clamp(0.0, 1.0)).collect() + }; + let mut old = Surface::new(fmt); + let r = Rect::new(-40, -20, 700, 300); + old.write_region(r, &pattern(r, 0.0)); + let mut new = old.clone(); + let changed = Rect::new(-40, -20, 400, 300); + new.write_region(changed, &pattern(changed, 1.3)); + let emptied = photocraft_geom::TileCoord::containing(600, 100).rect(); + drop(new.take_tiles(emptied)); + let added = Rect::new(800, 0, 900, 50); + new.write_region(added, &pattern(added, 2.1)); + (old, new) + } + + #[test] + fn keep_alpha_matches_the_serial_pass_at_every_depth() { + use photocraft_color::SampleType; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (old, new) = alpha_pair(sample); + let mut want = new.clone(); + keep_alpha_serial(&old, &mut want); + let mut got = new.clone(); + keep_alpha(&old, &mut got, &|| false).unwrap(); + assert!(got == want, "{sample:?}: parallel keep_alpha differs from the serial pass"); + assert_ne!(got, new, "{sample:?}: alpha was put back"); + } + } + + #[test] + fn keep_alpha_stops_when_cancelled_and_keeps_the_tiles() { + let (old, new) = alpha_pair(photocraft_color::SampleType::U8); + let mut got = new.clone(); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 2; + assert!(matches!(keep_alpha(&old, &mut got, &cancel), Err(EngineError::Cancelled))); + // The cancel was seen inside the pass (after some tiles), and no tile went missing. + assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 2); + assert!(new.tiles().all(|(c, _)| got.tile(*c).is_some())); + } + + /// #7423: Displace on a Smart Object keeps its map (read from an open document) in the smart + /// filter, as Photoshop embeds it, so the filter displaces and keeps displacing when the + /// object re-renders. A map chosen again replaces it. + #[test] + fn displace_on_a_smart_object_embeds_its_map() { + let mut s = Session::new(); + // Documents 0 and 1: a white and a black displacement map. + s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "black"})).unwrap(); + s.execute("file.new", json!({"width": 48, "height": 32, "background": "transparent"})).unwrap(); + s.edit("square", |doc, active| { + let fmt = doc.pixel_format(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(photocraft_geom::Rect::new(10, 10, 20, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let original = { + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + sm.cache.as_ref().unwrap().read_region(d.doc.bounds()) + }; + let smart = |s: &Session| { + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + (sm.smart_filters[0].params.clone(), sm.cache.as_ref().unwrap().read_region(d.doc.bounds())) + }; + let first_value = |params: &Value| photocraft_io::smart_map::displace_embedded_map(params).expect("an embedded map").data[0]; + + s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap(); + let (params, displaced) = smart(&s); + assert!(params.get("mapDocument").is_none(), "{params}"); + assert_eq!(first_value(¶ms), 1.0, "the white map"); + assert_ne!(displaced, original, "the smart object is displaced"); + // Re-rendered from its params alone (the filter hidden and shown again): still displaced. + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(smart(&s).1, original); + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap(); + assert_eq!(smart(&s).1, displaced); + + // Another map chosen for the filter replaces the embedded one. + s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {"mapDocument": 1}})).unwrap(); + let (params, again) = smart(&s); + assert!(params.get("mapDocument").is_none(), "{params}"); + assert_eq!(first_value(¶ms), 0.0, "the black map"); + assert_ne!(again, displaced, "displaced the other way"); + + // The stored params passed back whole (the old embedded map included) with another map + // named: the named map wins, for the filter's own edit and for a new Displace alike. + let mut back = params.as_object().unwrap().clone(); + back.insert("mapDocument".into(), json!(0)); + s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": back.clone()})).unwrap(); + assert_eq!(first_value(&smart(&s).0), 1.0, "the white map, not the embedded black one"); + back.insert("mapDocument".into(), json!(1)); + s.execute("filter.distort.displace", Value::Object(back)).unwrap(); + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + assert_eq!(first_value(&sm.smart_filters[1].params), 0.0, "the black map, not the passed-back white one"); + } } diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 068d37e9da..b2359ffa57 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -123,9 +123,11 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.lensBlur" => { - &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] - } + "filter.blur.lensBlur" => &[ + ("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), + ("depthMap", "none|transparency|layerMask|channel"), + ("distribution", "uniform|gaussian"), + ], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index dae5e9b98b..7aa726f8e0 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -173,6 +173,12 @@ pub fn params_for(id: &str, p: &Value) -> Option { }, }, // ---- 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), @@ -277,6 +283,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { 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), @@ -400,7 +407,8 @@ pub fn params_for(id: &str, p: &Value) -> Option { /// Ids whose rendering uses the foreground/background colours. fn uses_colours(id: &str) -> bool { - matches!(id, "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") || crate::gallery_cmds::uses_colours(id) + matches!(id, "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") + || crate::gallery_cmds::uses_colours(id) } /// Fills in session state a filter reads (the current colours) as explicit params, so the @@ -429,8 +437,19 @@ fn map_from_document(doc: &Document, layer: Option) -> Option } /// Resolves inputs that live outside the target layer: Displace's map (`mapPath`, or -/// `mapDocument` index with optional `mapLayer` id). +/// `mapDocument` index with optional `mapLayer` id), and the alpha channel Lens Blur reads its +/// depth from (`depthChannel`, an index or a name). pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::LensBlur { depth: DepthSource::Channel, depth_map, .. } = fp { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let channel = p + .get("depthChannel") + .ok_or_else(|| EngineError::Other("Lens Blur: `depthMap` channel needs `depthChannel` (an alpha channel's index or name)".into()))?; + let surface = crate::edit_menu_cmds::channel_mask(doc, channel) + .ok_or_else(|| EngineError::Other(format!("Lens Blur: the document has no alpha channel {channel}")))?; + *depth_map = Some(Arc::new(algo::Image::read(surface, doc.bounds()))); + return Ok(()); + } let FilterParams::Displace { map, .. } = fp else { return Ok(()) }; if map.is_some() { return Ok(()); @@ -438,7 +457,7 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<( let layer = p.get("mapLayer").and_then(Value::as_u64); let img = if let Some(path) = map_path(p) { map_from_path(path, layer)? - } else if let Some(d) = p.get("mapDocument") { + } else if let Some(d) = map_document(p) { let docs = s.documents(); let found = match d { Value::String(name) => docs.iter().find(|ds| ds.doc.name == *name || ds.path.as_deref() == Some(name.as_str())), @@ -453,10 +472,23 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<( Ok(()) } -fn map_path(p: &Value) -> Option<&str> { +/// Displace's params as a request for its map: a map named in them (`mapPath` or `mapDocument`) +/// wins over the one already embedded, which is read only when none is named. Otherwise a dialog +/// or agent passing the filter's stored params back with a new map got the old map again. +pub(crate) fn displace_request(p: &Value) -> Value { + if map_path(p).is_some() || map_document(p).is_some() { photocraft_io::smart_map::displace_without_map(p) } else { p.clone() } +} + +pub(crate) fn map_path(p: &Value) -> Option<&str> { p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) } +/// Displace's `mapDocument`: an index or a name. Null and a negative index (the dialog's "None") +/// name no document. +pub(crate) fn map_document(p: &Value) -> Option<&Value> { + p.get("mapDocument").filter(|d| !d.is_null() && d.as_i64().is_none_or(|i| i >= 0)) +} + /// Displace's map read from a file (flattened, or its `mapLayer`). fn map_from_path(path: &str, layer: Option) -> Result> { let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?; @@ -466,13 +498,15 @@ fn map_from_path(path: &str, layer: Option) -> Result> /// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not /// the map, so their re-render reads the map again here; without it the filter passed the pixels -/// through. `mapDocument` needs the session and is left unresolved. +/// through. The path is stored in the document, so it is followed only as +/// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved. pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { if let FilterParams::Displace { map, .. } = fp && map.is_none() && let Some(path) = map_path(p) { - *map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the displacement map", path, why))?; + *map = map_from_path(&file.to_string_lossy(), p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); } Ok(()) } @@ -614,6 +648,8 @@ pub fn specs() -> Vec { r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"## ), // Render + cmd!("filter.render.clouds", "Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##), + cmd!("filter.render.differenceClouds", "Difference Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##), cmd!( "filter.render.fibers", "Fibers…", @@ -660,7 +696,7 @@ pub fn specs() -> Vec { "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], - r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask|channel","depthChannel":index|name?,"focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), cmd!( "filter.blur.shapeBlur", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 0f32c5367d..490568e87c 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -220,6 +220,51 @@ fn lens_blur_uses_the_layer_mask_as_depth() { assert_ne!(pixels(&s), before); } +/// #7400: an alpha channel is a Lens Blur depth source, by name or index, as Photoshop lists +/// the document's channels in Depth Map › Source. A missing channel is an error that changes +/// nothing, and on a Smart Object (whose smart filters re-render without the channels) it is +/// refused instead of silently blurring without depth. +#[test] +fn lens_blur_reads_an_alpha_channel_as_depth() { + let mut s = session(); + // A white channel: depth 1 everywhere. Creating it targets it; the filter is for the layer. + s.execute("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let before = pixels(&s); + for channel in [json!("Depth Map"), json!(0)] { + let p = |focal: f64| json!({"radius": 6, "depthMap": "channel", "depthChannel": channel, "focalDistance": focal}); + // Focal distance 255 puts depth 1 in focus: nothing blurs. + s.execute("filter.blur.lensBlur", p(255.0)).unwrap(); + assert_eq!(pixels(&s), before, "{channel}: in focus"); + s.execute("filter.blur.lensBlur", p(0.0)).unwrap(); + assert_ne!(pixels(&s), before, "{channel}: out of focus blurs"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s), before); + } + for bad in [ + json!({"radius": 6, "depthMap": "channel"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": "Nope"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": 7}), + ] { + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("Lens Blur"), "{bad}: {e}"); + assert_eq!(pixels(&s), before, "{bad}: nothing changed"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "{bad}: the document was replaced"); + assert_eq!(st.history.past_len(), past, "{bad}: a history step was recorded"); + } + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", json!({"radius": 6, "depthMap": "channel", "depthChannel": "Depth Map"})).unwrap_err().to_string(); + assert!(e.contains("Smart Object"), "{e}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "no smart filter was added"); + assert_eq!(st.history.past_len(), past, "no history step"); +} + #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index e5450b2339..12b06bd76e 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -12,11 +12,12 @@ //! which puts it back and then returns to the chosen state. //! //! While floating, the document is unchanged; [`displayed`] gives the document with the piece at -//! its offset, for the canvas. The selection stays where it was until the drop. +//! its offset, for the canvas. The selection stays where it was until the drop, when it becomes +//! the piece's outline ([`CutParts::outline`]); the canvas shows that outline from the start. use std::sync::Arc; -use photocraft_color::PixelFormat; +use photocraft_color::{ColorMode, PixelFormat}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -32,6 +33,12 @@ pub struct CutParts { pub layer: LayerId, rest: Surface, piece: Surface, + piece_bounds: photocraft_geom::Rect, + /// The selection the moved pixels take, unmoved: the alpha the piece took, the selection + /// limited to the layer's transparency (Photoshop shrinks the marching ants to the piece as the + /// drag starts, #7538). `None` keeps the selection: a mask has no transparency, and an empty + /// piece no outline. + outline: Option, /// The pieces are the layer's mask (`"target":"mask"`), not its pixels. mask: bool, } @@ -55,41 +62,45 @@ impl CutParts { }; let fmt = surf.format(); let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); - let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) }; // A mask has no alpha: it keeps its format, and the cut leaves background colour. - let mut rest = if mask { surf.clone() } else { full.clone() }; + let base = if mask || fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) }; + let mut rest = base.clone(); let mut piece = Surface::new(with_alpha); + let mut outline: Option = None; let fill = !copy && (mask || doc.is_background(layer)); // A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas. - let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() }); + let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { base.content_bounds() }); if !b.is_empty() { - let n = with_alpha.channels(); - let a = n - 1; - let mut rp = full.read_region(b); - let mut pp = rp.clone(); - let w = b.width() as usize; - for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { - let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0); - if k <= 0.0 { - p.fill(0.0); - } else { - p[a] *= k; - } - if !copy && !fill { - r[a] *= 1.0 - k; - } - } + piece = cut_piece(&base, sel, b, with_alpha)?; + // The moved pixels' selection is what they took with them: the alpha the piece + // holds, which is the selection limited to the layer's own coverage (Photoshop's + // marching ants hug the piece as the drag starts, #7538). A mask has no outline. if !mask { - rest.write_region(b, &rp); + let n = with_alpha.channels(); + let cover: Vec = piece.try_read_region(b)?.chunks_exact(n).filter_map(|p| p.last().copied()).collect(); + let mut o = Surface::new(PixelFormat::new(ColorMode::Grayscale, sel.format().sample, false)); + o.try_write_region(b, &cover)?; + o.prune(); + outline = Some(o); } if fill { - crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?; + let opaque_bg = [background[0], background[1], background[2], 1.0]; + crate::pixels::try_fill_surface(&mut rest, b, opaque_bg, Some(sel), false)?; + if mask { + rest.prune(); + } + } else if !copy && !mask { + crate::pixels::try_lift_coverage(&mut rest, b, sel)?; } - rest.prune(); - piece.write_region(b, &pp); - piece.prune(); } - Ok(Self { layer, rest, piece, mask }) + let piece_bounds = piece.content_bounds(); + let outline = outline.filter(|o| !o.content_bounds().is_empty()); + Ok(Self { layer, rest, piece, piece_bounds, outline, mask }) + } + + /// The selection the moved pixels take, where they were cut (`None`: the selection stays). + pub fn outline(&self) -> Option<&Surface> { + self.outline.as_ref() } /// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer. @@ -107,10 +118,14 @@ impl CutParts { } else { l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))? }; + if self.piece_bounds.is_empty() { + *surf = self.rest.clone(); + return Ok(()); + } let mut out = self.rest.clone(); - let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy); + let piece = self.piece.translated(dx, dy, self.piece_bounds); if self.mask { - crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?; + crate::fill_cmds::composite_over(&mut out, &piece, self.piece_bounds.translate(dx, dy), None)?; } else { crate::transform_cmds::composite_over(&mut out, &piece); } @@ -119,6 +134,103 @@ impl CutParts { } } +/// Extract the selected pixels of `base` over `b` into a `with_alpha` surface, tile by tile in +/// parallel. Fully selected tiles on an alpha surface share `base`'s `Arc` directly. +fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha: PixelFormat) -> Result { + use photocraft_color::{SampleType, read_sample}; + use photocraft_geom::{TILE_SIZE, TileCoord}; + use photocraft_raster::Tile; + use rayon::prelude::*; + + let mut piece = Surface::new(with_alpha); + let src_fmt = base.format(); + let same_fmt = src_fmt == with_alpha; + let n = with_alpha.channels(); + let a = n - 1; + let dst_bpp = with_alpha.bytes_per_pixel(); + let src_bpp = src_fmt.bytes_per_pixel(); + let sb = with_alpha.sample.bytes(); + let alpha_off = dst_bpp - sb; + let src_empty_default = same_fmt && base.default_bytes().iter().all(|&v| v == 0); + let oom = |e| EngineError::Other(format!("not enough memory while floating selection ({e}); the document was not changed")); + let mut coords = crate::allocation::capacity(b.tiles().count(), "floating selection")?; + coords.extend(b.tiles()); + let tiles = coords + .into_par_iter() + .map(|tc| -> Result)>> { + let tr = tc.rect().intersect(&b); + let mask = crate::fill_cmds::Mask::of(sel, tc); + if mask.empty(tr) { + return Ok(None); + } + let src_tile = base.tile(tc); + if same_fmt && src_tile.is_none() && src_empty_default { + return Ok(None); + } + if same_fmt + && tr == tc.rect() + && mask.full(tr) + && let Some(t) = src_tile + { + return Ok(Some((tc, t.clone()))); + } + let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes()); + let mut out = piece.try_default_tile().map_err(oom)?; + let out_bytes = out.bytes_mut(); + let mut px = [0.0f32; 8]; + let mut has_content = false; + for y in tr.y0..tr.y1 { + let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize; + for x in tr.x0..tr.x1 { + let k = mask.at(x, y); + if k <= 0.0 { + continue; + } + let idx = row + (x - tc.tx * TILE_SIZE) as usize; + let dst_i = idx * dst_bpp; + let Some(dst_px) = out_bytes.get_mut(dst_i..dst_i + dst_bpp) else { continue }; + let src_i = if src_bytes.len() == src_bpp { 0 } else { idx * src_bpp }; + let Some(src_px) = src_bytes.get(src_i..src_i + src_bpp) else { continue }; + if same_fmt && k >= 1.0 { + if src_px.iter().any(|&v| v != 0) { + dst_px.copy_from_slice(src_px); + has_content = true; + } + continue; + } + if same_fmt { + for (c, v) in px.iter_mut().enumerate().take(n) { + *v = read_sample(src_px, src_fmt.sample, c); + } + // The selection lifts its share of a pixel's coverage, up to all of it + // (Photoshop): a layer's own transparency, loaded as the selection, lifts + // it whole (#7655). An opaque pixel splits as k and 1 − k. + px[a] = px[a].min(k); + } else { + for (c, v) in px.iter_mut().enumerate().take(a) { + *v = read_sample(src_px, src_fmt.sample, c); + } + px[a] = k; + } + if px[a] <= 0.0 { + continue; + } + photocraft_raster::encode_pixel(&with_alpha, &px[..n], dst_px); + let non_zero = match with_alpha.sample { + SampleType::U8 => dst_px.get(alpha_off) != Some(&0) || dst_px.iter().any(|&v| v != 0), + SampleType::U16 => dst_px.get(alpha_off..dst_bpp) != Some(&[0, 0]) || dst_px.iter().any(|&v| v != 0), + SampleType::F32 => dst_px.iter().any(|&v| v != 0), + }; + has_content |= non_zero; + } + } + Ok(has_content.then(|| (tc, Arc::new(out)))) + }) + .collect::>>()?; + piece.put_tiles(tiles.into_iter().flatten()); + Ok(piece) +} + /// A piece floating `offset` whole pixels from where it was cut, over document `revision`. #[derive(Clone, Debug)] pub struct Floating { @@ -126,6 +238,9 @@ pub struct Floating { pub offset: (i32, i32), pub revision: u64, pub parts: Arc, + /// The piece has been moved at least once, even if it is back where it was cut (nudged away + /// and back): its selection is the moved pixels' then, as each of Photoshop's nudges leaves it. + pub moved: bool, } /// The floating piece of `st`, if it is still valid (the document hasn't changed under it). @@ -177,14 +292,16 @@ fn float(s: &mut Session, p: &Value) -> Result { if floating(st).is_none() { let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?; - st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) }); + st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false }); } let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?; f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy)); + f.moved |= (dx, dy) != (0, 0); Ok(json!({"layer": f.layer.0, "offset": [f.offset.0, f.offset.1]})) } -/// Drop the floating piece into its layer and move the selection with it (one history step). +/// Drop the floating piece into its layer and move the selection with it, shrunk to the piece's +/// outline (one history step). pub fn drop_floating(s: &mut Session) -> Result { let Some(st) = s.active_mut() else { return Ok(json!({"dropped": false})) }; let Some(f) = floating(st).cloned() else { @@ -194,11 +311,21 @@ pub fn drop_floating(s: &mut Session) -> Result { st.floating = None; let (dx, dy) = f.offset; if (dx, dy) == (0, 0) { + // Back where it was cut: the pixels are as they were. A piece that never moved (a click) + // changes nothing; one nudged away and back keeps the moved pixels' selection (#7538). + if let Some(outline) = f.parts.outline().filter(|_| f.moved) { + let outline = outline.clone(); + s.edit("Move", |doc, _| { + doc.selection = Some(outline); + Ok(()) + })?; + } return Ok(json!({"dropped": true, "offset": [0, 0]})); } s.edit("Move", |doc, _| { f.parts.place(doc, dx, dy)?; - doc.selection = doc.selection.as_ref().map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); + let sel = f.parts.outline().or(doc.selection.as_ref()); + doc.selection = sel.map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); Ok(()) })?; Ok(json!({"dropped": true, "offset": [dx, dy]})) @@ -218,14 +345,17 @@ pub(crate) fn before_command(s: &mut Session, id: &str) -> Result> return Ok(None); } match id { - "edit.undo" | "edit.stepBackward" => { + // Edit › Undo and the History panel's Step Backward step back over the move itself. + "edit.undo" | "history.stepBackward" => { st.floating = None; Ok(Some(json!({"floating": "returned"}))) } - // Returning to a History state puts the piece back too (it never reached the document), - // and then moves the history: dropping it first would add a step and discard the redo - // states the jump may be heading for. - "history.selectState" => { + // Redo / Step Forward with nothing to step to leave the piece floating (and are refused). + "edit.redo" | "history.stepForward" if !st.history.can_redo() => Ok(None), + // Returning to a History state, or stepping forward to one, puts the piece back too (it + // never reached the document), and then moves the history: dropping it first would add + // a step and discard the redo states the step is heading for. + "history.selectState" | "edit.redo" | "history.stepForward" => { st.floating = None; Ok(None) } @@ -251,7 +381,7 @@ pub fn specs() -> Vec { label: "Drop Floating Selection", menu: &[], shortcut: None, - params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it: one history step)"##, + params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it, shrunk to the moved pixels: the selection limited to where the layer has pixels, as in Photoshop; a mask or an empty piece keeps the selection's shape; one history step)"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document".into()), journal: true, run: |s, _| drop_floating(s), diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index bb2ba5b91c..44c887643f 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -65,6 +65,43 @@ fn any_other_command_drops_it_and_undo_puts_it_back() { assert!(alpha(&st.doc, id, 12, 12) == 0.0 && alpha(&st.doc, id, 35, 20) == 1.0); } +/// The History panel's Step Backward / Step Forward treat a floating piece like Edit › Undo / +/// Redo: no Move step is recorded and the redo states survive. +#[test] +fn history_panel_steps_put_the_piece_back_and_keep_the_redo_states() { + for back in ["edit.undo", "history.stepBackward"] { + let (mut s, id) = session(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + assert_eq!(s.execute(back, json!({})).unwrap()["floating"], json!("returned"), "{back}"); + let st = s.active().unwrap(); + assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0, "{back}: piece back"); + assert_eq!((st.history.past_len(), st.history.can_redo()), (steps, false), "{back}: no Move step"); + } + for forward in ["edit.redo", "history.stepForward"] { + let (mut s, id) = session(); + // A redo state (the deselection), then a moved piece over the selection undo restored. + s.execute("select.deselect", json!({})).unwrap(); + s.undo(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + s.execute(forward, json!({})).unwrap(); + let st = s.active().unwrap(); + assert!(floating(st).is_none(), "{forward}: piece back"); + assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "{forward}: the move never reached the document"); + assert!(st.doc.selection.is_none(), "{forward}: stepped forward to the deselection"); + assert_eq!((st.history.past_len(), st.history.can_redo()), (steps + 1, false), "{forward}: no Move step"); + // Nothing to step forward to: refused, and the piece keeps floating. + let (mut s, _) = session(); + s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute(forward, json!({})).is_err(), "{forward}: nothing to redo"); + let st = s.active().unwrap(); + assert_eq!(floating(st).map(|f| f.offset), Some((20, 0)), "{forward}: still floating"); + assert_eq!(st.history.past_len(), steps); + } +} + #[test] fn needs_a_selection_and_an_unlocked_pixel_layer() { let mut s = Session::new(); @@ -189,3 +226,302 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour"); assert_eq!(mask_at(&s, 40, 12), 0.0); } + +/// #7655: a selection loaded from the layer's own transparency lifts its half-transparent pixels +/// whole, leaving nothing behind (Photoshop); a half selection of an opaque pixel still splits it. +#[test] +fn a_layers_own_transparency_floats_its_soft_pixels_whole() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(10, 10, 20, 30), &[1.0, 0.0, 0.0, 0.5]); + surf.fill_rect(Rect::new(20, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 25})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(alpha(d, id, 15, 20), 0.0, "no ghost where the soft pixels were"); + assert!((alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "they moved whole: {}", alpha(d, id, 15, 45)); + assert_eq!((alpha(d, id, 25, 20), alpha(d, id, 25, 45)), (0.0, 1.0)); + + // A copy (⌥) of them is as solid as they are, as Photoshop's Layer via Copy of the same + // selection is, and leaves them where they were. + s.undo(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 25, "copy": true})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert!((alpha(d, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "{} {}", alpha(d, id, 15, 20), alpha(d, id, 15, 45)); + + // A half selection of an opaque pixel splits it half and half, as before. + let (mut s, id) = session(); + s.execute("select.all", json!({})).unwrap(); + s.edit("half", |doc, _| { + let sel = doc.selection.as_mut().unwrap(); + sel.fill_rect(doc_rect(80, 60), &[0.5]); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert!((alpha(d, id, 15, 15) - 0.5).abs() < 0.01 && (alpha(d, id, 55, 15) - 0.5).abs() < 0.01); +} + +fn doc_rect(w: i32, h: i32) -> Rect { + Rect::new(0, 0, w, h) +} + +#[test] +fn float_shares_interior_tiles_on_cut_and_place() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap(); + s.tools.background = [0.2, 0.4, 0.8, 1.0]; + let id = s.active().unwrap().active_layer.unwrap(); + // Paint non-uniform pixels so original tiles are distinct. + s.edit("pattern", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + for ty in 0..4 { + for tx in 0..4 { + surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + // Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2). + s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap(); + s.execute("select.float", json!({})).unwrap(); + let st = s.active().unwrap(); + let orig = st.doc.layer(id).unwrap().surface().unwrap(); + let parts = &floating(st).unwrap().parts; + let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 }; + let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 }; + // Interior tiles of `piece` share the original layer's Arc. + assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap())); + assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap())); + // Interior tiles of the Background hole in `rest` share one solid fill Arc. + assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap())); + // Moving by a whole tile (256, 0) shares the interior tile directly in the preview document. + let shown = displayed(st, (256, 0)).unwrap(); + let shown_surf = shown.layer(id).unwrap().surface().unwrap(); + let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 }; + assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap())); +} + +#[test] +fn float_feathered_selection_preserves_partial_alpha_across_depths() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap(); + s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap(); + // Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha. + assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center"); + assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center"); + let edge_a = surf.rgba(60, 35)[3]; + assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}"); + } +} + +/// Coverage of `d`'s selection at (x, y). +fn selected(d: &Document, x: i32, y: i32) -> f32 { + d.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) +} + +/// #7538: a marquee drawn loosely around an object on a transparent layer shrinks to the object +/// once its pixels move, as in Photoshop, at every depth and when moving a copy. +#[test] +fn moving_shrinks_the_selection_to_the_moved_pixels_at_every_depth() { + for depth in [8, 16, 32] { + for copy in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + // A marquee with empty space all round the red square. + s.execute("select.rect", json!({"x": 4, "y": 6, "width": 34, "height": 30})).unwrap(); + s.execute("select.float", json!({"dx": 20, "dy": 5, "copy": copy})).unwrap(); + let st = s.active().unwrap(); + let outline = floating(st).unwrap().parts.outline().unwrap(); + assert_eq!(outline.content_bounds(), Rect::new(10, 10, 30, 30), "{depth}-bit, copy {copy}: the outline while floating"); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), Rect::new(4, 6, 38, 36), "the document's is untouched"); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(30, 15, 50, 35), "{depth}-bit, copy {copy}"); + assert_eq!((selected(doc, 40, 25), selected(doc, 26, 13)), (1.0, 0.0), "{depth}-bit, copy {copy}: the moved square only"); + // Undo brings back the marquee as it was drawn. + s.undo(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(4, 6, 38, 36), "{depth}-bit, copy {copy}"); + } + } +} + +/// The new selection is what each pixel took with it: the selection, at most the pixel's alpha. A +/// soft selection keeps its soft edge; a half-transparent pixel half selected moves whole. +#[test] +fn the_outline_is_what_the_moved_pixels_took() { + let (mut s, id) = session(); + s.edit("soften", |doc, _| { + // Half-transparent red over (30..40, 10..30), beside the opaque (10..30)² square. + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(30, 10, 40, 30), &[1.0, 0.0, 0.0, 0.5]); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 60, 50), &[0.5]); + sel.fill_rect(Rect::new(10, 10, 20, 30), &[1.0]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let near = |x: i32, y: i32, want: f32| assert!((selected(doc, x, y) - want).abs() < 0.01, "({x}, {y}): {} for {want}", selected(doc, x, y)); + near(25, 20, 1.0); // fully selected, opaque + near(35, 20, 0.5); // half selected, opaque: half of it moved + near(45, 20, 0.5); // half selected, half transparent: all of it moved + near(5, 5, 0.0); // selected, but nothing there + assert_eq!(doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 10, 50, 30)); +} + +/// Where the selection is the moved piece already (the Background, which has no transparency) +/// or the piece has no outline (a mask, or only transparent pixels), the selection just moves. +#[test] +fn the_selection_just_moves_without_transparency_to_shrink_to() { + // The Background. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(20, 5, 30, 15), "the Background"); + // Only transparent pixels selected. + let (mut s, _) = session(); + s.execute("select.rect", json!({"x": 40, "y": 5, "width": 10, "height": 10})).unwrap(); + s.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + assert!(floating(s.active().unwrap()).unwrap().parts.outline().is_none()); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(45, 5, 55, 15), "an empty piece"); + // The mask: it has no transparency of its own. + let (mut s, id) = session(); + s.edit("mask", |doc, _| { + doc.layer_mut(id).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all()); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 40, "height": 40})).unwrap(); + s.execute("select.float", json!({"dx": 5, "dy": 0, "target": "mask"})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), Rect::new(10, 5, 50, 45), "the mask"); +} + +/// #7538: an anti-aliased shape moved, dropped, then moved again from its shrunk selection leaves +/// nothing behind. With the selection shrunk to the pixels' alpha, the second move left +/// a × (1 − a) of each edge pixel (up to 25 %), a faint outline where the shape had been. +#[test] +fn moving_again_from_the_shrunk_selection_leaves_no_edge_behind() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 120, "height": 100, "background": "transparent", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 50, "height": 40, "ellipse": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let total = |doc: &Document| (0..100).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| alpha(doc, id, x, y)).sum::(); + let (before, soft) = { + let doc = &s.active().unwrap().doc; + (total(doc), (0..100).flat_map(|y| (0..120).map(move |x| (x, y))).filter(|&(x, y)| (0.01..0.99).contains(&alpha(doc, id, x, y))).count()) + }; + assert!(soft > 20, "{depth}-bit: the ellipse has anti-aliased edges ({soft})"); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 70, "height": 60})).unwrap(); + s.execute("select.float", json!({"dx": 40, "dy": 30})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + // The ellipse now sits at (60..110, 50..90); move it back where it started. + s.execute("select.float", json!({"dx": -40, "dy": -30})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let leftover = (50..100) + .flat_map(|y| (55..115).map(move |x| (x, y))) + .filter(|&(x, y)| x >= 72 || y >= 62) + .map(|(x, y)| alpha(doc, id, x, y)) + .fold(0.0f32, f32::max); + assert_eq!(leftover, 0.0, "{depth}-bit: nothing left where it was moved from"); + assert!((total(doc) - before).abs() < 0.05, "{depth}-bit: every edge pixel came back whole ({} vs {before})", total(doc)); + } +} + +/// #7538, measured in Photoshop 2025: after moving a soft-edged shape from a loose marquee, the +/// selection's strength at each edge pixel is that pixel's alpha (its marching ants run along the +/// edge's 50 % line, and Quick Mask shows the edge partly selected), and a feathered selection +/// over opaque pixels still lifts only part of them, leaving the rest behind. +#[test] +fn a_soft_edge_is_as_selected_as_it_is_opaque_and_feathered_moves_leave_the_rest() { + let (mut s, id) = session(); + s.edit("soft edge", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + for (x, a) in [(30, 0.75), (31, 0.5), (32, 0.25)] { + surf.fill_rect(Rect::new(x, 10, x + 1, 30), &[1.0, 0.0, 0.0, a]); + } + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 40, "height": 30})).unwrap(); + s.execute("select.float", json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + for (x, a) in [(39, 1.0), (40, 0.75), (41, 0.5), (42, 0.25), (43, 0.0)] { + assert!((selected(doc, x, 20) - a).abs() < 0.01, "x {x}: selected {} where alpha {a}", selected(doc, x, 20)); + assert!((alpha(doc, id, x, 20) - a).abs() < 0.01, "x {x}: the pixel moved whole"); + } + assert!((10..20).all(|x| alpha(doc, id, x, 20) == 0.0), "nothing left where it was"); + + // A feathered (half) selection over opaque pixels lifts half of them and leaves half. + let (mut s, id) = session(); + s.edit("half", |doc, _| { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(10, 10, 30, 30), &[0.5]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("select.float", json!({"dx": 30, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!((alpha(doc, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(doc, id, 45, 20) - 0.5).abs() < 0.01, "half left, half moved"); +} + +/// #7538: arrow nudges add up to one floating piece, so up then down is back at no offset. The +/// selection is the moved pixels' all the same, as after each of Photoshop's nudges, in one step; +/// a piece that never moved (a click) changes nothing. +#[test] +fn nudging_away_and_back_keeps_the_moved_pixels_selection() { + let (mut s, _) = session(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 40, "height": 40})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.float", json!({"dx": 0, "dy": -1})).unwrap(); + s.execute("select.float", json!({"dx": 0, "dy": 1})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(10, 10, 30, 30)), "the moved square's"); + assert_eq!(st.history.past_len(), steps + 1, "one step"); + s.undo(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(2, 2, 42, 42)), "undone"); + + // A click: cut, never moved, dropped. + s.execute("select.float", json!({"dx": 0, "dy": 0})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().map(|s| s.content_bounds()), Some(Rect::new(2, 2, 42, 42)), "a click keeps the marquee"); + assert_eq!(st.history.past_len(), steps, "and adds no step"); +} diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index 7a401594fd..2ea953609f 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -72,8 +72,8 @@ fn frame_group(s: &mut Session, grouped: &Value) -> Result { crate::layer_multi_cmds::layer_bounds(d.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?).filter(|r| !r.is_empty()).unwrap_or_else(|| d.doc.bounds()); s.edit("Frame from Layers", |doc, _| { - let g = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?; - g.mask = Some(clip_mask(rect)); + let mask = crate::commands::mask_at_depth(clip_mask(rect), doc.depth)?; + doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?.mask = Some(mask); Ok(()) })?; Ok(json!({"layer": gid.0, "frame": [rect.x0, rect.y0, rect.width(), rect.height()]})) @@ -133,7 +133,8 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { }; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let gid = s.edit("New Frame", |doc, active| { - let mask = if ellipse { ellipse_mask(rect, doc.bounds()) } else { clip_mask(rect.intersect(&doc.bounds())) }; + let mask = + crate::commands::mask_at_depth(if ellipse { ellipse_mask(rect, doc.bounds()) } else { clip_mask(rect.intersect(&doc.bounds())) }, doc.depth)?; let name = name.unwrap_or_else(|| doc.next_layer_name("Frame")); let gid = match content { Some(id) => { diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 3598ea56b0..af550a367b 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -1,8 +1,8 @@ -//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside -//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a +//! Layers panel: showing or hiding a layer's effects and Smart Filters under its row (the +//! shared disclosure triangle). View state ([`crate::DocState::fx_collapsed`]): no history step, and a //! clean document stays clean. -use photocraft_doc::LayerId; +use photocraft_doc::{Layer, LayerContent, LayerId}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -10,15 +10,23 @@ use crate::{EngineError, Result, Session}; const ID: &str = "layer.setEffectsExpanded"; +/// A `layer.setEffectsExpanded` parameter error. fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: ID.into(), msg: msg.into() } } +/// The command is available while some layer has effects or Smart Filters to disclose. fn has_effects(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) } + if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) } } +/// Whether the layer has a list to disclose: effects, or Smart Filters on a Smart Object. +fn has_children(l: &Layer) -> bool { + !l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) +} + +/// An optional boolean param: `None` when absent or null, an error when it is anything but true or false. fn opt_bool(p: &Value, key: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -37,12 +45,12 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?), Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)), }; - let with_fx: Vec = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect(); + let with_fx: Vec = st.doc.walk().iter().filter(|(_, _, l)| has_children(l)).map(|(_, _, l)| l.id).collect(); let current = match layer { Some(id) => { let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if l.effects.items.is_empty() { - return Err(bad(format!("layer {} has no effects", id.0))); + if !has_children(l) { + return Err(bad(format!("layer {} has no effects or smart filters", id.0))); } !st.fx_collapsed.contains(&id) } @@ -59,13 +67,14 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result { Ok(json!({"expanded": expanded, "layers": targets.len()})) } +/// Register `layer.setEffectsExpanded`: view state, so it is not journaled and adds no undo step. pub fn specs() -> Vec { vec![CommandSpec { id: ID, label: "Expand/Collapse Effects", menu: &[], shortcut: None, - params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##, + params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects or smart filters; view state, not an undo step)"##, enabled: has_effects, run: set_expanded, journal: false, @@ -88,10 +97,35 @@ mod tests { (s, a, b) } + /// Whether the layer's effects list is shown, i.e. it is not in the document's collapsed set. fn open(s: &Session, id: u64) -> bool { !s.active().unwrap().fx_collapsed.contains(&LayerId(id)) } + /// Smart Filters hide and show with the layer's effects, as view state: no document edit, revision or history step. + #[test] + fn smart_filters_share_the_effects_disclosure_without_an_edit() { + let (mut s, a, b) = session(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + let doc = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let steps = s.active().unwrap().history.past_len(); + s.execute(ID, json!({"layer": smart.0, "expanded": false})).unwrap(); + assert!(!open(&s, smart.0)); + assert!(open(&s, b)); + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.past_len(), steps); + s.execute(ID, json!({"expanded": false, "all": true})).unwrap(); + assert!(!open(&s, smart.0) && !open(&s, b)); + s.execute(ID, json!({"expanded": true, "all": true})).unwrap(); + assert!(s.active().unwrap().fx_collapsed.is_empty()); + } + + /// Toggling one layer or all of them flips only the disclosure state: no history step, no duplicates, and the document stays clean. #[test] fn toggles_one_layer_or_all_without_history() { let (mut s, a, b) = session(); diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..138be5bbeb 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,14 +28,23 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// The `angle` param (degrees) normalised to (-180, 180], or `default` when absent. Rejects +/// a non-number or one beyond f32 range: an infinite angle renders NaN and can't be saved. +pub(crate) fn angle_param(cmd: &str, p: &Value, default: f32) -> Result { + let Some(a) = p.get("angle") else { return Ok(default) }; + let a = a.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`angle` is a number (degrees)"))?.rem_euclid(360.0); + Ok(if a > 180.0 { a - 360.0 } else { a }) +} + fn num(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } @@ -149,7 +158,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } @@ -176,12 +185,7 @@ pub fn apply_set(layer: &Layer, f: &Fill, canvas: Rect, p: &Value, fg: [f32; 4], } let new_style = style_param(CMD, p)?; let explicit_geometry = ["angle", "scale", "offset"].iter().any(|k| p.get(*k).is_some()); - if let Some(a) = p.get("angle") { - *angle = a.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`angle` is a number (degrees)"))?.rem_euclid(360.0); - if *angle > 180.0 { - *angle -= 360.0; - } - } + *angle = angle_param(CMD, p, *angle)?; if p.get("scale").is_some() { let v = num(p, "scale").filter(|v| *v > 0.0).ok_or_else(|| bad(CMD, "`scale` is a positive percentage"))?; *scale = clamp_scale(v / 100.0); diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index efcdb494d9..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/group_view_cmds.rs b/crates/engine/src/group_view_cmds.rs index b95acff6a7..52eadb782c 100644 --- a/crates/engine/src/group_view_cmds.rs +++ b/crates/engine/src/group_view_cmds.rs @@ -102,6 +102,9 @@ mod tests { s.execute("layer.new.layer", json!({"name": "deep"})).unwrap(); let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap(); let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap(); + // Group Layers creates collapsed groups; the tests start from both open. + assert!(!expanded(&s, inner) && !expanded(&s, outer)); + s.execute(ID, json!({"all": true, "expanded": true})).unwrap(); (s, inner, outer) } diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index c9c91b3d14..14bfbffd54 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -194,7 +194,7 @@ fn delete_snapshot(s: &mut Session, p: &Value) -> Result { } fn can_delete(s: &Session) -> std::result::Result<(), String> { - s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) + s.active().filter(|d| d.history.past_len() > 0).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) } fn has_doc(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/history_menu_cmds.rs b/crates/engine/src/history_menu_cmds.rs new file mode 100644 index 0000000000..5d079f7cf4 --- /dev/null +++ b/crates/engine/src/history_menu_cmds.rs @@ -0,0 +1,247 @@ +//! The History panel menu (☰, #2966): Step Forward, Step Backward and Clear History. Delete and +//! New Document are [`crate::history_cmds`]; New Snapshot and History Options live elsewhere. +//! +//! Step Forward / Step Backward move one state along the History list, like Edit › Redo / Undo +//! (which step through the list too), but report a step that isn't there as an error. Clear +//! History takes every state but the current one off the list without changing the image; it is +//! undoable: Edit › Undo puts the states back until the next step is recorded. + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// `{states, current, label}`: the list length, the current state's index (0 = oldest) and its +/// name, after a History panel menu command. +fn list(s: &Session) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let entries = st.history.entries(); + Ok(json!({ "states": entries.len() + st.history.redo_labels().count(), "current": st.history.past_len(), "label": entries.last() })) +} + +fn step(s: &mut Session, forward: bool) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let possible = if forward { st.history.can_redo() } else { st.history.can_undo() }; + if !possible { + return Err(EngineError::Other(if forward { "no later state to step forward to" } else { "no earlier state to step back to" }.into())); + } + let moved = if forward { s.redo() } else { s.undo() }; + if !moved { + return Err(EngineError::Other("the history can't step right now".into())); + } + list(s) +} + +/// History panel › Clear History: only the current state stays in the list; the image doesn't +/// change. Edit › Undo brings the states back. +fn clear(s: &mut Session, _p: &Value) -> Result { + if s.active_job().is_some() { + return Err(EngineError::Other("the history can't change while a job runs".into())); + } + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + st.coalesce = None; + if !st.history.clear_states() { + return Err(EngineError::Other("the current state is the only state".into())); + } + // Edit › Fade blends against the previous state, which is no longer in the list. + s.edit_state.fade = None; + list(s) +} + +fn can_step_forward(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.can_redo() { Ok(()) } else { Err("no later state".into()) } +} + +fn can_step_backward(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.can_undo() { Ok(()) } else { Err("no earlier state".into()) } +} + +fn can_clear(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.history.past_len() > 0 || st.history.can_redo() { Ok(()) } else { Err("the current state is the only state".into()) } +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "history.stepForward", + label: "Step Forward", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: makes the next History state current (Err when there is none)"##, + enabled: can_step_forward, + run: |s, _| step(s, true), + journal: true, + }, + CommandSpec { + id: "history.stepBackward", + label: "Step Backward", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: makes the previous History state current (Err when there is none)"##, + enabled: can_step_backward, + run: |s, _| step(s, false), + journal: true, + }, + CommandSpec { + id: "history.clear", + label: "Clear History", + menu: &[], + shortcut: None, + params: r##"{} → {states,current,label}: removes every History state but the current one without changing the image; Edit › Undo restores them"##, + enabled: can_clear, + run: clear, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// A document with three steps after Open: entries Open, New Layer, Fill, Fill. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s + } + + fn entries(s: &Session) -> Vec { + s.active().unwrap().history.entries() + } + + #[test] + fn step_backward_and_forward_move_one_state() { + let mut s = session(); + let all = entries(&s); + let last = s.active().unwrap().doc.clone(); + assert!(!s.is_enabled("history.stepForward"), "no later state yet"); + assert!(s.is_enabled("history.stepBackward")); + let r = s.execute("history.stepBackward", json!({})).unwrap(); + assert_eq!((r["states"].as_u64(), r["current"].as_u64()), (Some(all.len() as u64), Some(all.len() as u64 - 2))); + assert_eq!(r["label"], json!(all[all.len() - 2])); + assert_eq!(entries(&s), all[..all.len() - 1].to_vec()); + assert!(s.is_enabled("history.stepForward")); + s.execute("history.stepBackward", json!({})).unwrap(); + s.execute("history.stepForward", json!({})).unwrap(); + let r = s.execute("history.stepForward", json!({})).unwrap(); + assert_eq!(r["current"].as_u64(), Some(all.len() as u64 - 1)); + assert_eq!(entries(&s), all); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &last), "back at the last state"); + assert!(s.execute("history.stepForward", json!({})).is_err(), "nothing to step forward to"); + } + + #[test] + fn step_backward_stops_at_the_oldest_state() { + let mut s = session(); + while s.is_enabled("history.stepBackward") { + s.execute("history.stepBackward", json!({})).unwrap(); + } + assert_eq!(entries(&s), ["Open"]); + assert!(s.execute("history.stepBackward", json!({})).is_err()); + assert_eq!(entries(&s), ["Open"], "a refused step changes nothing"); + } + + #[test] + fn clear_history_keeps_the_image_and_leaves_one_state() { + let mut s = session(); + s.execute("history.stepBackward", json!({})).unwrap(); + let all = entries(&s); + let doc = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let r = s.execute("history.clear", json!({})).unwrap(); + assert_eq!((r["states"].as_u64(), r["current"].as_u64()), (Some(1), Some(0))); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), [all.last().unwrap().clone()], "the current state is the only one"); + assert!(!st.history.can_redo(), "redo states are cleared too"); + assert!(Arc::ptr_eq(&st.doc, &doc) && st.revision == revision, "the image doesn't change"); + assert!(!s.is_enabled("history.clear"), "nothing left to clear"); + assert!(s.execute("history.clear", json!({})).is_err()); + assert!(!s.is_enabled("history.stepForward") && !s.is_enabled("history.deleteState")); + assert!(!s.is_enabled("edit.fade")); + } + + #[test] + fn undo_after_clear_history_brings_the_states_back() { + let mut s = session(); + s.execute("history.stepBackward", json!({})).unwrap(); + let all = entries(&s); + let redo: Vec = s.active().unwrap().history.redo_labels().map(str::to_string).collect(); + let doc = s.active().unwrap().doc.clone(); + s.execute("history.clear", json!({})).unwrap(); + assert!(s.is_enabled("edit.undo") && s.is_enabled("history.stepBackward"), "Clear History is undoable"); + s.execute("edit.undo", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), all); + assert_eq!(st.history.redo_labels().map(str::to_string).collect::>(), redo); + assert!(Arc::ptr_eq(&st.doc, &doc), "undoing it doesn't change the image either"); + // A step after Clear History forgets the cleared states. + s.execute("history.clear", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + assert_eq!(entries(&s).len(), 2); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.is_enabled("edit.undo"), "the cleared states are gone"); + } + + #[test] + fn clear_history_and_its_undo_keep_a_saved_document_clean() { + let mut s = session(); + let st = s.active_mut().unwrap(); + st.saved_revision = st.revision; + s.execute("history.clear", json!({})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "Clear History is no edit"); + let revision = s.active().unwrap().revision; + assert!(s.execute("edit.undo", json!({})).unwrap().as_bool().unwrap()); + let st = s.active().unwrap(); + assert!(!st.is_dirty(), "nor is undoing it"); + assert!(st.revision > revision, "the panels still see the list change"); + // A modified document stays modified. + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("history.clear", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + } + + #[test] + fn clear_history_leaves_other_documents_alone() { + let mut s = session(); + let first = entries(&s); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.active_index(), Some(1)); + s.execute("history.clear", json!({})).unwrap(); + assert_eq!(entries(&s).len(), 1); + let other = &s.documents()[0]; + assert_eq!(other.history.entries(), first, "the other document keeps its states"); + assert!(!other.history.is_cleared() && other.history.can_undo()); + assert!(s.set_active(0)); + assert!(s.is_enabled("history.clear") && s.is_enabled("history.stepBackward")); + s.execute("history.stepBackward", json!({})).unwrap(); + assert_eq!(entries(&s).len(), first.len() - 1); + } + + #[test] + fn history_menu_commands_refuse_without_a_document_and_ignore_odd_params() { + let mut s = Session::new(); + for id in ["history.stepForward", "history.stepBackward", "history.clear"] { + assert!(!s.is_enabled(id), "{id}"); + assert!(s.execute(id, json!({})).is_err(), "{id} without a document"); + } + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for id in ["history.stepForward", "history.stepBackward", "history.clear"] { + assert!(!s.is_enabled(id), "{id}: only the opening state"); + for p in [json!({}), json!(null), json!([1]), json!({"state": -1}), json!("x")] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + assert_eq!(entries(&s), ["Open"]); + } + } +} diff --git a/crates/engine/src/history_select_cmds.rs b/crates/engine/src/history_select_cmds.rs index e05a859a5a..2409722121 100644 --- a/crates/engine/src/history_select_cmds.rs +++ b/crates/engine/src/history_select_cmds.rs @@ -59,8 +59,10 @@ fn select_state(s: &mut Session, p: &Value) -> Result { } let damage = crate::layer_multi_cmds::step_damage(&start, &doc); st.doc = doc; + // Stepping only over selection changes keeps a clean document clean (#6865). + let keep_clean = st.keeps_clean(&start); crate::restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; if at != target { // Not reached (the bounds were checked): the document is consistent at the state reached. diff --git a/crates/engine/src/history_source_cmds.rs b/crates/engine/src/history_source_cmds.rs new file mode 100644 index 0000000000..c84cbe8400 --- /dev/null +++ b/crates/engine/src/history_source_cmds.rs @@ -0,0 +1,380 @@ +//! The History Brush's source (#2966): the History panel's source column, at the left of every +//! state and snapshot, sets what the History Brush (and Fill › History) restores from. Exactly one +//! source per document; by default the oldest state held (Photoshop's default is the first +//! snapshot, the document as opened, which that state is until it falls off the history). +//! +//! A state source is kept by state, not by index ([`photocraft_ops::History::set_brush_source`]), +//! so it stays on its state while older states fall off, and that state is kept when it would fall +//! off the history itself. A snapshot source is kept by snapshot id ([`HistorySource::Snapshot`]), +//! a separate namespace from the states. Once the source state or snapshot is deleted, or a redo +//! state source is discarded by a new step, there is no source until one is set, and the History +//! Brush refuses to paint without an explicit `state` or `snapshot`, as Photoshop does. +//! (Photoshop keeps a source discarded by a new step in the list; that needs states outside the +//! linear history, so here it is gone.) Edit › Purge › Histories keeps the snapshots (and a +//! snapshot source) and resets a state source to the default. +//! +//! Rows: `state` is a History panel row everywhere here (this command, `paint.historyBrush`, Fill +//! › History and `historySource` in `doc_inspect`): the states (0 = oldest), the current one, then +//! the greyed redo states, the next redo first. + +use std::sync::Arc; + +use photocraft_doc::Document; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const CMD: &str = "history.setSource"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// What the History Brush paints from: a History panel state row, or a snapshot by id. The two +/// are separate, so snapshot 1 and state row 1 never stand for each other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HistorySource { + /// A History panel row ([`photocraft_ops::History::row_id`]). + State(usize), + /// A History panel snapshot ([`crate::history_cmds::Snapshot::id`]). + Snapshot(u64), +} + +/// The document's History Brush source, `None` once the source state or snapshot is gone. +pub fn source(st: &DocState) -> Option { + match st.source_snapshot { + Some(id) => st.snapshots.iter().any(|s| s.id == id).then_some(HistorySource::Snapshot(id)), + None => st.history.brush_source().map(HistorySource::State), + } +} + +fn source_of(st: &DocState, src: HistorySource) -> Option> { + match src { + HistorySource::State(i) => st.history.row_doc(i, &st.doc), + HistorySource::Snapshot(id) => st.snapshots.iter().find(|s| s.id == id).map(|s| s.doc.clone()), + } +} + +/// A `state` or `snapshot` param (`None` when neither is passed), checked against the document. +fn parse(st: &DocState, p: &Value, cmd: &str) -> Result> { + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let get = |k: &str| p.get(k).filter(|v| !v.is_null()); + let index = |k: &str, v: &Value| v.as_u64().ok_or_else(|| bad(format!("`{k}` must be a non-negative integer, not {v}"))); + match (get("state"), get("snapshot")) { + (Some(_), Some(_)) => Err(bad("pass a `state` or a `snapshot`, not both".into())), + (Some(v), None) => { + let rows = st.history.entries().len().saturating_add(st.history.redo_labels().count()); + let i = usize::try_from(index("state", v)?).ok().filter(|i| *i < rows); + let i = i.ok_or_else(|| bad(format!("no history state {v}: a History panel row (0..{rows}, the greyed redo rows last)")))?; + Ok(Some(HistorySource::State(i))) + } + (None, Some(v)) => { + let id = index("snapshot", v)?; + if !st.snapshots.iter().any(|s| s.id == id) { + return Err(bad(format!("no snapshot {id}"))); + } + Ok(Some(HistorySource::Snapshot(id))) + } + (None, None) => Ok(None), + } +} + +/// The document the History Brush and Fill › History paint from: the `state` (History panel row) +/// or `snapshot` passed, else the source set in the History panel. A source of another canvas +/// size is refused rather than painting shifted pixels (after Crop, Canvas Size or Image Size): +/// Photoshop refuses too, "the current canvas size does not match that of the history state" +/// (its message as users report it; Adobe's help doesn't document the case). +pub(crate) fn paint_source(st: &DocState, p: &Value, cmd: &str) -> Result> { + let doc = match parse(st, p, cmd)? { + Some(src) => source_of(st, src).ok_or_else(|| EngineError::Other("that history state or snapshot is gone".into()))?, + None => source(st) + .and_then(|src| source_of(st, src)) + .ok_or_else(|| EngineError::Other("the History Brush source was deleted: set a new source in the History panel".into()))?, + }; + if doc.size != st.doc.size { + return Err(EngineError::Other(format!( + "the current canvas size ({}×{}) does not match that of the History Brush source ({}×{}): set a source of this size in the History panel", + st.doc.size.width, st.doc.size.height, doc.size.width, doc.size.height + ))); + } + Ok(doc) +} + +/// `history.setSource {state}` or `{snapshot}`: make a History panel row or a snapshot the History +/// Brush's source. +fn set_source(s: &mut Session, p: &Value) -> Result { + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + match parse(st, p, CMD)? { + None => return Err(bad("pass the `state` (a History panel row, 0 = oldest) or the `snapshot` id to paint from")), + Some(HistorySource::State(i)) => { + if !st.history.set_brush_source(i) { + return Err(bad(format!("no history state {i}"))); + } + st.source_snapshot = None; + } + Some(HistorySource::Snapshot(id)) => { + // No state is the source now, so none is kept past its time. + st.history.reset_brush_source(); + st.source_snapshot = Some(id); + } + } + Ok(source_json(st)) +} + +/// The source as `doc_inspect` reports it: `{state,label}` (a History panel row) or +/// `{snapshot,name}`; `null` once its state or snapshot is gone. +pub fn source_json(st: &DocState) -> Value { + match source(st) { + None => Value::Null, + Some(HistorySource::State(i)) => { + let label = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_string)).nth(i); + json!({ "state": i, "label": label }) + } + Some(HistorySource::Snapshot(id)) => { + json!({ "snapshot": id, "name": st.snapshots.iter().find(|s| s.id == id).map(|s| s.name.clone()) }) + } + } +} + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Set History Brush Source", + menu: &[], + shortcut: None, + params: r##"{"state":row} | {"snapshot":id} (state: a History panel row, 0 = oldest state, then the current one, then the greyed redo states; snapshot: a history.newSnapshot id) → {state,label} | {snapshot,name}: paint.historyBrush and Fill › History use this source when neither `state` nor `snapshot` is passed"##, + enabled: has_doc, + run: set_source, + journal: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A white 8×8 document filled red, then green: rows Open, Fill (red), Fill (green). + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 16, "background": "white"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s + } + + fn rgb(s: &Session) -> [f32; 3] { + let st = s.active().unwrap(); + let px = st.doc.layers[0].surface().unwrap().rgba(4, 4); + [px[0], px[1], px[2]] + } + + fn brush(s: &mut Session, extra: Value) { + let mut p = json!({"points": [[4, 4]], "size": 40, "hardness": 100}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + s.execute("paint.historyBrush", p).unwrap(); + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01) + } + + #[test] + fn default_source_is_the_oldest_state() { + let mut s = session(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], json!({"state": 0, "label": "Open"})); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + } + + #[test] + fn the_history_brush_and_fill_paint_from_the_set_source() { + let mut s = session(); + let r = s.execute(CMD, json!({"state": 1})).unwrap(); + assert_eq!(r, json!({"state": 1, "label": "Fill"})); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"]["state"], 1); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "the red state: {:?}", rgb(&s)); + // An explicit `state` still wins. + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "Fill › History uses the source too: {:?}", rgb(&s)); + // Setting the source is not a step. + assert_eq!(s.active().unwrap().history.entries().len(), 6); + } + + /// The common Photoshop workflow: apply, step back, set the source on the greyed state, paint. + #[test] + fn a_redo_state_can_be_the_source_until_a_new_step_discards_it() { + let mut s = session(); + assert!(s.undo()); + s.execute(CMD, json!({"state": 2})).unwrap(); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the greyed green state: {:?}", rgb(&s)); + // Painting was a new step, which discarded the green state: no source until one is set. + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + let before = rgb(&s); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + assert!(s.execute("edit.fill", json!({"contents": "history"})).is_err()); + assert!(close(rgb(&s), before), "nothing painted"); + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "an explicit state still paints: {:?}", rgb(&s)); + s.execute(CMD, json!({"state": 0})).unwrap(); + brush(&mut s, json!({})); + } + + #[test] + fn the_source_follows_its_state_as_older_states_fall_off() { + let mut s = session(); + s.active_mut().unwrap().history.max_states = 2; + s.execute(CMD, json!({"state": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + // Open fell off: the red state is row 0 now and still the source. + let st = s.active().unwrap(); + assert_eq!((st.history.entries().len(), st.history.brush_source()), (3, Some(0))); + // It would fall off next: it is kept, as in Photoshop, and paints red. + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + brush(&mut s, json!({})); + let st = s.active().unwrap(); + assert_eq!((st.history.entries()[0].as_str(), st.history.brush_source()), ("Fill", Some(0))); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + } + + #[test] + fn deleting_the_source_state_leaves_no_source() { + let mut s = session(); + s.execute(CMD, json!({"state": 2})).unwrap(); + s.execute("history.deleteState", json!({"state": 2})).unwrap(); + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + s.execute(CMD, json!({"state": 1})).unwrap(); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + } + + /// Edit › Purge › Histories clears the states: the source goes back to the default instead of + /// leaving every document without one (#3536 review). + #[test] + fn purging_histories_resets_a_state_source() { + let mut s = session(); + s.execute(CMD, json!({"state": 1})).unwrap(); + s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], json!({"state": 0, "label": "Fill"})); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the only state left: {:?}", rgb(&s)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + } + + /// After Canvas Size (or Crop, Image Size) the old states have another size: painting from one + /// would shift the pixels, so the History Brush and Fill › History refuse, as Photoshop does. + #[test] + fn a_source_of_another_canvas_size_is_refused() { + let mut s = session(); + s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap(); + for cmd in ["paint.historyBrush", "edit.fill"] { + for state in [json!(null), json!(0), json!(2)] { + let p = + if cmd == "edit.fill" { json!({"contents": "history", "state": state}) } else { json!({"points": [[4, 4]], "size": 40, "state": state}) }; + let e = s.execute(cmd, p.clone()).unwrap_err().to_string(); + assert!(e.contains("canvas size"), "{cmd} {p}: {e}"); + } + } + assert_eq!(s.active().unwrap().history.entries().len(), 4, "nothing painted"); + // The state after the resize is the same size: it paints. + s.execute(CMD, json!({"state": 3})).unwrap(); + brush(&mut s, json!({})); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + // A snapshot of the old size is refused the same way. + let snap = s.execute("history.newSnapshot", json!({})).unwrap()["snapshot"].clone(); + s.execute("image.canvasSize", json!({"width": 8, "height": 8, "anchor": "topLeft"})).unwrap(); + s.execute(CMD, json!({"snapshot": snap})).unwrap(); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + } + + /// `state` is a History panel row for the History Brush and Fill too: the greyed redo rows + /// count, as in `history.setSource` and `historySource`. + #[test] + fn the_brush_and_fill_take_redo_rows() { + let mut s = session(); + assert!(s.undo()); + brush(&mut s, json!({"state": 2})); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "the greyed green state: {:?}", rgb(&s)); + let mut s = session(); + assert!(s.undo()); + s.execute("edit.fill", json!({"contents": "history", "state": 2})).unwrap(); + assert!(close(rgb(&s), [0.0, 1.0, 0.0]), "{:?}", rgb(&s)); + assert!(s.execute("edit.fill", json!({"contents": "history", "state": 4})).is_err(), "no such row"); + } + + /// A snapshot as the source (the column on snapshot rows): kept by snapshot id, apart from the + /// state rows; deleting it leaves no source; Purge › Histories keeps it. + #[test] + fn a_snapshot_can_be_the_source() { + let mut s = session(); + assert!(s.undo()); + let snap = s.execute("history.newSnapshot", json!({"name": "Red"})).unwrap()["snapshot"].as_u64().unwrap(); + assert_eq!(snap, 1, "snapshot 1, while state row 1 is the red fill too: they must not mix"); + assert!(s.redo()); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let r = s.execute(CMD, json!({"snapshot": snap})).unwrap(); + assert_eq!(r, json!({"snapshot": 1, "name": "Red"})); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], r); + // No state is the source now: the default would be state 0 (white). + assert_eq!(s.active().unwrap().history.brush_source(), Some(0)); + brush(&mut s, json!({})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "the snapshot paints: {:?}", rgb(&s)); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + // An explicit snapshot or state still wins. + brush(&mut s, json!({"state": 0})); + assert!(close(rgb(&s), [1.0, 1.0, 1.0]), "{:?}", rgb(&s)); + brush(&mut s, json!({"snapshot": snap})); + assert!(close(rgb(&s), [1.0, 0.0, 0.0]), "{:?}", rgb(&s)); + // Purging histories keeps the snapshots and so the source. + s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"], r); + // A state source replaces it. + s.execute(CMD, json!({"state": 0})).unwrap(); + assert_eq!(crate::inspect::document(s.active().unwrap())["historySource"]["state"], 0); + s.execute(CMD, json!({"snapshot": snap})).unwrap(); + // Deleting the snapshot leaves no source. + s.execute("history.deleteSnapshot", json!({"snapshot": snap})).unwrap(); + assert!(crate::inspect::document(s.active().unwrap())["historySource"].is_null()); + assert!(s.execute("paint.historyBrush", json!({"points": [[4, 4]], "size": 40})).is_err()); + assert!(s.execute("edit.fill", json!({"contents": "history"})).is_err()); + assert!(s.execute(CMD, json!({"snapshot": snap})).is_err(), "it is gone"); + } + + #[test] + fn bad_params_and_no_document_are_errors() { + assert!(matches!(Session::new().execute(CMD, json!({"state": 0})), Err(EngineError::NoDocument | EngineError::Disabled(..)))); + let mut s = session(); + for p in [ + json!({}), + json!({"state": null}), + json!({"state": -1}), + json!({"state": 3}), + json!({"state": 1e300}), + json!({"state": 1.5}), + json!({"state": "1"}), + json!({"state": [1]}), + json!({"snapshot": 0}), + json!({"snapshot": 1}), + json!({"snapshot": -1}), + json!({"snapshot": "Open"}), + json!({"snapshot": "Open", "state": 1}), + json!({"snapshot": 1, "state": 1}), + ] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + } + assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed"); + } +} diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..d52522ca35 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -16,7 +16,8 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } /// Applies `f` to every pixel surface of a layer tree: content (raster or -/// cached text/shape/smart pixels), fill caches, and (if `masks`) masks. +/// cached text/shape/smart pixels), fill caches, stored video frames (current and original), +/// and (if `masks`) masks. pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn FnMut(&mut Surface, bool)) { for l in layers { if masks && let Some(m) = &mut l.mask { @@ -25,6 +26,13 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn if let Some(fc) = &mut l.fill_cache { f(&mut fc.surface, false); } + // The displayed content is only a copy of the frame at the playhead; the other frames + // would keep the old geometry and depth and come back stale when scrubbing (#4956). + if let Some(v) = &mut l.video { + for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) { + f(frame, false); + } + } match &mut l.content { LayerContent::Raster(s) => f(s, false), LayerContent::Text(t) => { @@ -54,6 +62,18 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn } } +/// Drops every fill cache of a layer tree. A fill layer covers the whole canvas, but its cache +/// (the pixels a PSD stored) only the old one: after the canvas grows the fill must re-render +/// from its model, or the added area stays transparent (#7362). +fn drop_fill_caches(layers: &mut [Layer]) { + for l in layers { + l.fill_cache = None; + if let LayerContent::Group(g) = &mut l.content { + drop_fill_caches(&mut g.children); + } + } +} + /// Converts every pixel surface of a layer tree (masks included) to `depth`. pub(crate) fn convert_layers_depth(layers: &mut [Layer], depth: SampleType) { for_each_surface(layers, true, &mut |surf, _| { @@ -103,6 +123,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { def.layer = new_id; } } + for slice in &mut doc.slices.list { + if let Some(&new_id) = slice.layer.and_then(|layer| ids.get(&layer)) { + slice.layer = Some(new_id); + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › @@ -171,6 +196,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); + // An Indexed Color document holds only its colour table's colours, and a save quantises to + // them: interpolation showed blends that the saved file didn't have (#6019). + let resample = match resample { + Method::Filter(_) | Method::Automatic if d.doc.mode == ColorMode::Indexed => Method::Filter(Resample::Nearest), + m => m, + }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. @@ -212,7 +243,8 @@ fn image_size(s: &mut Session, p: &Value) -> Result { } s.edit("Image Size", |doc, _| { if let Some(r) = dpi { - doc.resolution_dpi = r.clamp(1.0, 10_000.0); + // Type keeps its pixel size; resampling below then scales it with the pixels. + crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0)); } let filter = match resample { Method::None => return Ok(()), @@ -241,10 +273,23 @@ pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, Err(bad(cmd, format!("{nw} x {nh} px is {mp} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size{hint}"))) } +/// Like [`check_resample_budget`] for a command that, besides resampling the document to +/// `nw` x `nh` px, holds `work_bytes_per_px` more bytes per pixel of full-canvas working buffers +/// (a composite, a mask, an output layer) at once. +pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, work_bytes_per_px: u64, hint: &str) -> Result<()> { + let bpp = (doc.pixel_format().bytes_per_pixel().max(1) as u64).saturating_add(work_bytes_per_px); + let px = u64::from(nw).saturating_mul(u64::from(nh)); + if px.saturating_mul(bpp) <= MAX_RESAMPLE_BYTES { + return Ok(()); + } + let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; + Err(bad(cmd, format!("{nw} x {nh} px is {} megapixels; this conversion is limited to {max_mp} megapixels. Choose a smaller size{hint}", px / 1_000_000))) +} + /// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution /// mode. Callers check [`check_resample_budget`] first. -fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { +pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { @@ -256,7 +301,16 @@ fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { *surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas); }); let k = ((sx + sy) / 2.0) as f32; - for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k)); + for_each_layer(&mut doc.layers, &mut |l| { + scale_effects(&mut l.effects, k); + // Canvas resampling scales the whole appearance, including shape strokes (#8341). + // Dash lengths and offsets are already expressed in multiples of this width. + if let LayerContent::Shape(sh) = &mut l.content + && let Some(stroke) = &mut sh.stroke + { + stroke.width *= k; + } + }); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas); } @@ -273,6 +327,17 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// The flattened document, or an error when the canvas is over the compositor's render cap +/// ([`photocraft_compose::MAX_RENDER_PIXELS`]; `flatten` returns an empty buffer then). +fn flatten_whole(doc: &Document, cmd: &str) -> Result { + let buf = photocraft_compose::flatten(doc); + let (w, h) = (doc.size.width, doc.size.height); + if buf.px.len() as u64 != u64::from(w) * u64::from(h) { + return Err(bad(cmd, format!("the document ({w} x {h} px) is too large to flatten (limit {} pixels)", photocraft_compose::MAX_RENDER_PIXELS))); + } + Ok(buf) +} + /// Moves every surface, channel, selection and guide by `(dx, dy)`. /// /// Fails, before moving anything, when some pixels would land outside the i32 coordinate @@ -328,19 +393,58 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi && let LayerContent::Raster(surf) = &mut bg.content { let old = old_canvas.translate(dx, dy); - let fmt = surf.format(); - let px = photocraft_raster::from_rgba(&fmt, [extension[0], extension[1], extension[2], 1.0]); - let keep = surf.content_bounds().intersect(&old); - let saved = surf.to_interleaved(keep); - surf.fill_rect(Rect::from_xywh(0, 0, r.width(), r.height()), &px); - if !keep.is_empty() { - surf.write_interleaved(keep, &saved); - } + let px = photocraft_raster::from_rgba(&surf.format(), [extension[0], extension[1], extension[2], 1.0]); + fill_background_extension(surf, Rect::from_xywh(0, 0, r.width(), r.height()), old, &px); + } + if extends { + drop_fill_caches(&mut doc.layers); } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); Ok(()) } +/// Fills the area an extension adds to the Background's `canvas` (all of it outside the `old` +/// canvas, and any gap there) with `px`, under the pixels the Background already holds there: +/// those a crop with Delete Cropped Pixels off hid show again instead of being painted over (#7086). +/// Only the added strips are touched, so the cost follows the added area, not the canvas. +fn fill_background_extension(surf: &mut Surface, canvas: Rect, old: Rect, px: &[f32]) { + let keep = surf.content_bounds().intersect(&old).intersect(&canvas); + let strips = if keep.is_empty() { + vec![canvas] + } else { + vec![ + Rect::new(canvas.x0, canvas.y0, canvas.x1, keep.y0), + Rect::new(canvas.x0, keep.y1, canvas.x1, canvas.y1), + Rect::new(canvas.x0, keep.y0, keep.x0, keep.y1), + Rect::new(keep.x1, keep.y0, canvas.x1, keep.y1), + ] + }; + let n = surf.channels(); + let a = n.saturating_sub(1); + let pa = px.get(a).copied().unwrap_or(1.0); + // Without an alpha channel hidden pixels can't be told from empty ones: fill the strips. + let has_alpha = surf.format().alpha; + for strip in strips.into_iter().filter(|r| !r.is_empty()) { + if !has_alpha || !surf.has_tiles_in(strip) { + surf.fill_rect(strip, px); + continue; + } + // The Background's own pixels over `px` (source-over). + let mut d = surf.read_region(strip); + for p in d.chunks_exact_mut(n) { + let ta = p.get(a).copied().unwrap_or(0.0); + let oa = ta + pa * (1.0 - ta); + for (c, &f) in p.iter_mut().zip(px).take(a) { + *c = if oa > 0.0 { (*c * ta + f * pa * (1.0 - ta)) / oa } else { 0.0 }; + } + if let Some(alpha) = p.get_mut(a) { + *alpha = oa; + } + } + surf.write_region(strip, &d); + } +} + /// The nine documented canvas-size anchors, in the order the command lists them, each mapped to /// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge). const ANCHORS: [(&str, (f64, f64)); 9] = [ @@ -397,6 +501,9 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { s.edit("Canvas Size", |doc, _| { translate_doc(doc, "image.canvasSize", dx, dy)?; doc.size = Size::new(nw, nh); + if nw > ow as u32 || nh > oh as u32 { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); @@ -414,14 +521,8 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { && let Some(bg) = doc.background_mut() && let LayerContent::Raster(surf) = &mut bg.content { - let fmt = surf.format(); - let px = photocraft_raster::from_rgba(&fmt, ext); - let keep = surf.content_bounds().intersect(&old); - let saved = surf.to_interleaved(keep); - surf.fill_rect(canvas, &px); - if !keep.is_empty() { - surf.write_interleaved(keep, &saved); - } + let px = photocraft_raster::from_rgba(&surf.format(), ext); + fill_background_extension(surf, canvas, old, &px); } Ok(()) })?; @@ -480,7 +581,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(EngineError::Other("nothing to crop: pass x/y/width/height or make a selection".into())); } - s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); + s.set_sampler_map(target.sampler_map(r)); let extension = s.tools.background; s.edit("Crop", |doc, active| { let original = doc.size; @@ -507,12 +608,27 @@ impl CropTarget { /// Applies the target to a document that has just been cropped (inside the crop's history step). fn apply(&self, doc: &mut Document) { if let Some((w, h)) = self.size { - resample_doc(doc, w, h, self.filter); + // As for Image Size: an Indexed Color document holds only its colour table's colours (#6019). + let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter }; + resample_doc(doc, w, h, filter); } if let Some(r) = self.resolution { doc.resolution_dpi = r; } } + + /// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels + /// when [`Self::apply`] resamples the cropped area `r` (as Image Size scales them). + fn sampler_map(&self, r: Rect) -> photocraft_geom::Affine { + let shift = photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0)); + match self.size { + Some((w, h)) if r.width() > 0 && r.height() > 0 => { + let (sx, sy) = (f64::from(w) / f64::from(r.width()), f64::from(h) / f64::from(r.height())); + shift.then(&photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }) + } + _ => shift, + } + } } /// The highest resolution (ppi) a crop may set, as for Image Size. @@ -525,8 +641,10 @@ fn crop_target(p: &Value) -> Result { let side = |key: &str| -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), + // Whole-number floats (`100.0`) count too, as for the crop rectangle. Some(v) => v .as_u64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=f64::from(MAX_CROP_SIDE)).contains(f)).map(|f| f as u64)) .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) .map(Some) @@ -629,9 +747,24 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar rotated: true, }; crate::analysis_cmds::compound(s, "Crop", |s| { + // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame + // makes the rotation cost the frame's size, not the canvas's (a small turned crop of a + // 10 MP photo took over half a second). + if delete { + let keep = turned_crop_source(size, new, angle, target); + s.edit("Crop", |doc, _| { + for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep)); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = crop_surface(&ch.surface, keep); + } + // The crop drops the selection anyway. + doc.selection = None; + Ok(()) + })?; + } crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). - s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); + s.set_sampler_map(crop_target.sampler_map(target)); let extension = s.tools.background; s.edit("Crop", |doc, active| { crop_doc(doc, CMD, target, delete, extension)?; @@ -646,15 +779,35 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) } +/// The pixels a turned crop reads, in document coordinates before the turn: the source of the +/// `target` frame (on the `new` canvas the rotation by `-angle` makes of `old`), grown by the widest +/// interpolation kernel's reach so every sample the frame's pixels take is kept. +fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect { + const KERNEL_REACH: f64 = 4.0; + // The inverse of `rotate_arbitrary`'s map: x = c·u + s·v + cx, y = −s·u + c·v + cy, where + // (u, v) is the point relative to the new canvas's centre. + let (sn, cs) = (-angle).to_radians().sin_cos(); + let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); + let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (px, py) in [(target.x0, target.y0), (target.x1, target.y0), (target.x0, target.y1), (target.x1, target.y1)] { + let (u, v) = (f64::from(px) - nx, f64::from(py) - ny); + let (x, y) = (cs * u + sn * v + cx, -sn * u + cs * v + cy); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + // `as` saturates: a frame near the coordinate limits keeps more, never less. + Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32) +} + /// Image → Trim. fn trim(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); - let buf = photocraft_compose::flatten(&d.doc); + let buf = flatten_whole(&d.doc, "image.trim")?; let based = p.get("basedOn").and_then(Value::as_str).unwrap_or("transparent"); let w = canvas.width() as usize; let reference = match based { - "topLeft" => Some(buf.px[0]), + "topLeft" => buf.px.first().copied(), "bottomRight" => buf.px.last().copied(), _ => None, }; @@ -698,30 +851,35 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result { return Ok(Value::Null); } s.edit("Bit Depth", |doc, _| { - // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured - // in Photoshop 27.11), so the colour samples cross the document's tone curve when the - // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear - // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. - let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); - let to_linear = depth == SampleType::F32; - let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); - if crosses && !to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); - } - convert_layers_depth(&mut doc.layers, depth); - if crosses && to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); - } - for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - let f = ch.surface.format().with_sample(depth); - ch.surface = ch.surface.convert(f); - } - doc.depth = depth; + change_depth(doc, depth); Ok(()) })?; Ok(Value::Null) } +pub(crate) fn change_depth(doc: &mut Document, depth: SampleType) { + let current = doc.depth; + // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured + // in Photoshop 27.11), so the colour samples cross the document's tone curve when the + // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear + // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. + let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); + let to_linear = depth == SampleType::F32; + let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); + if crosses && !to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); + } + convert_layers_depth(&mut doc.layers, depth); + if crosses && to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); + } + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + let f = ch.surface.format().with_sample(depth); + ch.surface = ch.surface.convert(f); + } + doc.depth = depth; +} + /// Image → Duplicate. fn duplicate(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -729,13 +887,19 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.id = photocraft_doc::DocId::fresh(); doc.name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} copy", d.doc.name)); if p.get("mergedOnly").and_then(Value::as_bool).unwrap_or(false) { - let buf = photocraft_compose::flatten(&doc); + let buf = flatten_whole(&doc, "image.duplicate")?; let fmt = doc.pixel_format(); let mut surf = Surface::new(fmt); let vals: Vec = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); surf.write_region(doc.bounds(), &vals); surf.prune(); - doc.layers = vec![Layer::new("Background", LayerContent::Raster(surf))]; + // As Photoshop: a source with a Background gives one (opaque, locked); otherwise a plain + // "Layer 1" that keeps the composite's transparency. + let has_background = doc.background().is_some(); + let mut merged = Layer::new(if has_background { "Background" } else { "Layer 1" }, LayerContent::Raster(surf)); + merged.locks.transparency = has_background; + merged.locks.position = has_background; + doc.layers = vec![merged]; // A merged-only copy has no original layers for saved comps to restore. // Don't carry dangling comp state into a single-layer flattened document. doc.layer_comps.clear(); @@ -907,6 +1071,28 @@ mod tests { &s.active().unwrap().doc } + /// Document-wide conversions must move every stored video frame, not just the displayed one (#4956). + #[test] + fn crop_moves_stored_video_frames() { + let mut s = session(); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 60, "height": 30})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + let red = vec![1.0, 0.0, 0.0, 1.0]; + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(20, 10), red); + assert_eq!(frame.pixel(5, 5)[3], 0.0); + } + } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the /// resolution changes. @@ -938,6 +1124,28 @@ mod tests { } } + /// #6019: Image Size on an Indexed Color document makes no colours the colour table doesn't have. + #[test] + fn image_size_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in ["bicubic", "automatic", "bilinear", "lanczos", "preserveDetails"] { + s.execute("image.imageSize", json!({"width": 8, "resample": resample})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a /// Background or a 200 % export got a translucent frame), and a full selection stays full. /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands @@ -1000,6 +1208,28 @@ mod tests { assert_eq!(r, json!({"width": 40, "height": 20})); } + /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is + /// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump. + #[test] + fn image_size_with_a_new_resolution_scales_type_with_the_pixels_only() { + for (params, k) in [(json!({"width": 200, "height": 200, "resolution": 144}), 2), (json!({"resolution": 144, "resample": "none"}), 1)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hi", "size": 12})).unwrap(); + let height = |s: &Session| doc(s).layers.last().unwrap().surface().unwrap().content_bounds().height(); + let h0 = height(&s); + assert!(h0 > 4, "{h0}"); + s.execute("image.imageSize", params.clone()).unwrap(); + assert!(height(&s).abs_diff(h0 * k) <= 2, "{params}: {h0} -> {}", height(&s)); + let d = doc(&s); + let LayerContent::Text(t) = &d.layers.last().unwrap().content else { panic!("type layer") }; + let mut t = t.clone(); + crate::type_cmds::refresh(d, &mut t); + let after = t.cache.unwrap().content_bounds().height(); + assert!(after.abs_diff(h0 * k) <= 2, "{params}: re-rendered {h0} -> {after}"); + } + } + #[test] fn image_size_reports_the_size_it_applied() { // #1815: every path reports the document's size afterwards, at every depth. @@ -1170,6 +1400,55 @@ mod tests { assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]); } + /// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the + /// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over + /// with the background colour (#7086). + #[test] + fn extending_the_canvas_keeps_the_backgrounds_hidden_pixels() { + for depth in [8, 16, 32] { + let hidden = |s: &mut Session| { + s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.background_mut().unwrap(); + bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 50, 100), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 50, "y": 0, "width": 50, "height": 100, "deleteCroppedPixels": false})).unwrap(); + s.tools.background = [0.0, 0.0, 0.0, 1.0]; + }; + let mut s = Session::new(); + hidden(&mut s); + s.execute("image.revealAll", json!({})).unwrap(); + let surf = doc(&s).background().unwrap().surface().unwrap(); + assert_eq!(doc(&s).size, Size::new(100, 100), "{depth}-bit"); + assert_eq!(surf.rgba(10, 50), [1.0, 0.0, 0.0, 1.0], "{depth}-bit: Reveal All shows the hidden red half"); + assert_eq!(surf.rgba(80, 50), [1.0, 1.0, 1.0, 1.0], "{depth}-bit"); + + // Canvas Size wider than the hidden pixels: they show, the rest takes the colour. + let mut s = Session::new(); + hidden(&mut s); + s.execute("image.canvasSize", json!({"width": 120, "height": 100, "anchor": "right"})).unwrap(); + let surf = doc(&s).background().unwrap().surface().unwrap(); + assert_eq!(surf.rgba(5, 50), [0.0, 0.0, 0.0, 1.0], "{depth}-bit: past the hidden pixels the background colour"); + assert_eq!(surf.rgba(30, 50), [1.0, 0.0, 0.0, 1.0], "{depth}-bit: Canvas Size shows the hidden red half"); + assert_eq!(surf.rgba(100, 50), [1.0, 1.0, 1.0, 1.0], "{depth}-bit"); + } + } + + /// A Background without an alpha channel (a Grayscale PSD import) has no hidden pixels to tell + /// from empty ones: the added area takes the colour, the kept area stays (#7086). + #[test] + fn an_alpha_less_background_extension_takes_the_colour() { + let mut surf = Surface::new(photocraft_color::PixelFormat::GRAY8); + surf.fill_rect(Rect::new(-50, 0, 100, 100), &[0.6]); + let kept = surf.pixel(50, 50); + fill_background_extension(&mut surf, Rect::new(-60, 0, 100, 100), Rect::new(0, 0, 100, 100), &[0.0]); + assert_eq!(surf.pixel(-55, 50), vec![0.0], "past the old pixels the colour"); + assert_eq!(surf.pixel(-30, 50), vec![0.0], "no alpha: the added area takes the colour"); + assert_eq!(surf.pixel(50, 50), kept, "the kept area is unchanged"); + } + /// A document (`mode`, `depth`) with a white Background and a layer holding a red pixel at /// (12, 8) and a blue one at (30, 3). fn marked(mode: &str, depth: u32) -> Session { @@ -1212,7 +1491,10 @@ mod tests { for y in 0..20 { for x in 0..10 { let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y)); - assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); + // A fully transparent pixel's colour is invisible, and the turned crop + // doesn't turn the pixels it deletes, which the warp spreads colour from. + let n = if b[3] > 1e-3 { 4 } else { 1 }; + assert!(a.iter().rev().zip(b.iter().rev()).take(n).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); } } } @@ -1253,6 +1535,61 @@ mod tests { } } + /// A turned crop that deletes the cropped pixels turns only what lands in the frame; inside the + /// new canvas every layer, mask and channel is exactly what turning everything gives (the + /// frame spans several tiles, at several depths and angles). + #[test] + fn turned_crop_deleting_pixels_matches_turning_the_whole_canvas() { + use photocraft_color::PixelFormat; + let pixels = || (0..500).flat_map(|y| (0..700).map(move |x| (x, y))); + for depth in [8, 16, 32] { + for angle in [3.0, -7.5, 33.0, 181.0] { + let mut sessions = [true, false].map(|delete| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 700, "height": 500, "depth": depth, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let wave = |x: i32, y: i32, k: i32| ((x * 7 + y * 13 + k * 31) % 97) as f32 / 96.0; + let all = Rect::new(0, 0, 700, 500); + let rgba: Vec = pixels().flat_map(|(x, y)| [wave(x, y, 0), wave(x, y, 1), wave(x, y, 2), wave(y, x, 3)]).collect(); + let gray: Vec = pixels().map(|(x, y)| wave(y, x, 4)).collect(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().write_region(all, &rgba); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.write_region(all, &gray); + l.mask = Some(mask); + let bg = doc.background_mut().unwrap().surface_mut().unwrap(); + bg.write_region(all, &pixels().flat_map(|(x, y)| [wave(x, y, 5), wave(x, y, 6), wave(x, y, 7), 1.0]).collect::>()); + let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(all, &gray); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + let p = json!({"x": 150, "y": 120, "width": 330, "height": 290, "angle": angle, "deleteCroppedPixels": delete}); + s.execute("image.crop", p).unwrap(); + s + }); + let [deleted, kept] = &mut sessions; + let (a, b) = (doc(deleted), doc(kept)); + assert_eq!(a.size, b.size, "{depth} {angle}"); + let canvas = a.bounds(); + for (la, lb) in a.layers.iter().zip(&b.layers) { + let (sa, sb) = (la.surface().unwrap(), lb.surface().unwrap()); + assert!(sa.to_interleaved(canvas) == sb.to_interleaved(canvas), "{depth} {angle}: layer {}", la.name); + if let (Some(ma), Some(mb)) = (&la.mask, &lb.mask) { + assert!(ma.surface.to_interleaved(canvas) == mb.surface.to_interleaved(canvas), "{depth} {angle}: mask of {}", la.name); + } + } + assert!(a.channels[0].surface.to_interleaved(canvas) == b.channels[0].surface.to_interleaved(canvas), "{depth} {angle}: alpha channel"); + // Deleting keeps nothing past the canvas. + let layer = a.layers[1].surface().unwrap(); + assert!(canvas.contains_rect(&layer.content_bounds()), "{depth} {angle}: {:?}", layer.content_bounds()); + } + } + } + /// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document. #[test] fn crop_at_angle_zero_is_the_plain_crop() { @@ -1372,6 +1709,40 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + /// #7722: a W x H crop of an Indexed Color document resamples like Image Size (#6019), with + /// Nearest Neighbor, so no pixel gets a blend that isn't in the colour table. + #[test] + fn crop_target_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in [None, Some("bicubic"), Some("automatic"), Some("bilinear"), Some("lanczos")] { + let mut p = json!({"x": 0, "y": 0, "width": 2, "height": 1, "targetWidth": 8, "targetHeight": 1}); + if let Some(r) = resample { + p["resample"] = json!(r); + } + s.execute("image.crop", p).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample:?}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + + #[test] + fn crop_target_accepts_whole_number_floats() { + // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. + let mut s = session(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 20, "height": 10, "targetWidth": 40.0, "targetHeight": 20.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(40, 20)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -1381,6 +1752,10 @@ mod tests { json!({"targetWidth": 0, "targetHeight": 10}), json!({"targetWidth": -5, "targetHeight": 10}), json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": -1.0, "targetHeight": 10}), + json!({"targetWidth": 0.0, "targetHeight": 10}), + json!({"targetWidth": 1e39, "targetHeight": 10}), + json!({"targetWidth": 300_001.0, "targetHeight": 10}), json!({"targetWidth": "10", "targetHeight": 10}), json!({"targetWidth": 300_001, "targetHeight": 10}), json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), @@ -1490,6 +1865,22 @@ mod tests { assert_eq!(doc(&s).depth, SampleType::U8); } + /// Duplicate › Merged Layers Only gives one locked Background when the source has one and a + /// plain "Layer 1" (keeping transparency) when it doesn't, as Photoshop does. + #[test] + fn merged_duplicate_is_a_background_only_when_the_source_has_one() { + let mut s = session(); + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = doc(&s); + assert!(d.is_background(d.layers[0].id), "source has a Background: the merged copy is one"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap(); + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = doc(&s); + assert!(d.background().is_none(), "transparent source: no Background in the copy"); + assert_eq!(d.layers[0].name, "Layer 1"); + } + #[test] fn duplicate_document() { let mut s = session(); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 6068503aec..f708d856fd 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource}; +use photocraft_doc::{Adjustment, Layer, LayerContent, SmartContentsId, SmartSource}; use serde_json::{Value, json}; use crate::{DocState, Session}; @@ -43,6 +43,7 @@ pub fn document(d: &DocState) -> Value { "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), + "historySource": crate::history_source_cmds::source_json(d), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), "canRedo": d.history.can_redo(), @@ -105,6 +106,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMa "bounds": bounds, "selected": selected.contains(&l.id), "linkGroup": l.link_group, + "hasXmp": l.xmp.is_some(), }); match &l.content { LayerContent::Group(g) => { @@ -118,6 +120,11 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMa // The same kind and parameters the adjustment commands take (`inBlack` 0..255, …), // not the internal model. v["adjustment"] = json!({"kind": crate::commands::adjustment_kind(a), "params": crate::adjust_params::to_params(a)}); + // Kept as imported and never rendered (#6541): agents and the Layers panel can tell. + if let Adjustment::Unsupported { psd_key, .. } = a { + v["adjustment"]["unsupported"] = json!(true); + v["adjustment"]["psdKey"] = json!(psd_key); + } } LayerContent::Smart(sm) => { v["smartFilters"] = Value::Array( @@ -210,6 +217,32 @@ mod tests { assert!(!adj.to_string().contains('_'), "{adj}"); } + /// #6541: an adjustment PhotoCraft keeps but cannot render is reported as such, not as an Invert. + #[test] + fn an_unsupported_adjustment_layer_is_reported_as_kept_not_rendered() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + s.edit("unsupported fixture", |doc, active| { + let l = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3] })); + let id = l.id; + doc.layers.push(l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + let info = s.execute("document.inspect", json!({})).unwrap(); + let layers = info["layers"].as_array().unwrap(); + let unsupported = &layers[0]["adjustment"]; + assert_eq!(unsupported["kind"], "unsupported", "{unsupported}"); + assert_eq!(unsupported["unsupported"], json!(true)); + assert_eq!(unsupported["psdKey"], "xyzw"); + // A real Invert reports neither flag. + let invert = &layers[1]["adjustment"]; + assert_eq!(invert["kind"], "invert"); + assert!(invert.get("unsupported").is_none(), "{invert}"); + } + #[test] fn document_inspect_reports_raster_and_vector_masks_when_nested() { let cases = [(false, None), (true, None), (false, Some(true)), (false, Some(false)), (true, Some(true))]; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 17d8288f8b..57846617dd 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -212,6 +212,11 @@ struct Running { label: String, params: Value, journal: bool, + /// The recording (`ActionState::take`) running when the job started, if any (set by + /// `dispatch`, once recording resumes). Only that recording gets the job's step: one started + /// later must not pick up an edit made before it. + recorded_by: Option, + pre_selected: Option>, doc: Option, /// The document as the job saw it: applying fails if it was edited meanwhile. snapshot: Option>, @@ -219,12 +224,36 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + /// The document's layer selection when the job started: applying targets it, not whatever + /// got selected meanwhile (a Type click does that without editing the document, #7366). + layers: Option, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] handle: Option>, } +/// A document's active layer, selected layers and selection anchor. +#[derive(Clone, PartialEq)] +struct LayerSelection { + active: Option, + selected: Vec, + anchor: Option, +} + +impl LayerSelection { + fn of(st: &crate::DocState) -> Self { + Self { active: st.active_layer, selected: st.selected_layers.clone(), anchor: st.layer_anchor } + } + + fn set(self, st: &mut crate::DocState) { + st.active_layer = self.active; + st.selected_layers = self.selected; + st.layer_anchor = self.anchor; + crate::channel_cmds::fix_view(st); + } +} + impl Running { fn info(&self) -> JobInfo { JobInfo { @@ -323,6 +352,9 @@ pub fn run( ) -> Result { let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); let _authorization = AuthorizationScope::enter(authorization); + // The job's document: stored paths it follows are resolved against it on any thread. + let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from); + let _document = crate::doc_path::scope(document.as_deref()); if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); @@ -330,15 +362,17 @@ pub fn run( if s.jobs.pending.is_some() { return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); } - let (doc, snapshot) = match (lock_document, s.active()) { - (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc))), + let (doc, snapshot, layers) = match (lock_document, s.active()) { + (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), (true, None) => return Err(EngineError::NoDocument), - (false, _) => (None, None), + (false, _) => (None, None, None), }; let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); + let apply_document = document.clone(); let erased: Apply = Box::new(move |s, any| { let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(apply_document.as_deref()); let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; apply(s, *t) }); @@ -352,6 +386,7 @@ pub fn run( // no way to get it back, so the error is reported instead. let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || { let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(document.as_deref()); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged")))); // The receiver is gone when the job was cancelled: nothing to report. @@ -370,12 +405,15 @@ pub fn run( label: label.to_string(), params: Value::Null, journal: true, + recorded_by: None, + pre_selected: None, doc, snapshot, ctx, started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + layers, rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -453,6 +491,14 @@ pub enum OpenSource { /// The command id open jobs report (there is no engine command: opening is the shell's). pub const OPEN_JOB: &str = "file.open"; +/// The import settings files are opened and placed with. +pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::ImportOptions { + photocraft_io::ImportOptions { + max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, + demosaic: prefs.raw_defaults.demosaic.method(), + } +} + impl Session { /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation @@ -471,7 +517,9 @@ impl Session { /// Open a file in the background: read (for a path) and decode on a worker, with progress /// (per layer for PSD/PSB) and cancellation, then add the document like /// [`Session::open_document`] (Color Settings policies apply). `name` names the document. - /// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. When + /// The job's result is `{document, docId, name, warnings, color}`; on wasm it runs inline. + /// `document` is the tab index at apply time; resolve `docId` before handling a delayed + /// completion, since earlier handlers may have moved or closed tabs. When /// Color Settings ask about the file's profile, the document is held back instead and the /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. @@ -479,7 +527,7 @@ impl Session { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); - let max_svg_group_depth = self.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; + let opts = import_options(self.prefs()); self.start_job( OPEN_JOB, json!({"name": name}), @@ -493,17 +541,29 @@ impl Session { }; ctx.check()?; ctx.progress(0.02, "Decoding"); - let imported = ctx - .stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) - .map_err(|e| match e { + let imported = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&name_w, &bytes, &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.document, || ctx.check())?; + // Type layers need the shared text engine, whose first use waits for the + // installed-font scan started at launch (minutes on some machines). Wait here, + // so the shell's missing-font check after the open finds it ready instead of + // freezing the window (#7332). + if crate::type_extra_cmds::has_type_layers(&imported.document) { + ctx.progress(0.97, "Loading fonts"); + let _ = photocraft_text::shared(); + ctx.check()?; + } Ok(imported) }, - move |s, r: photocraft_io::ImportResult| { + move |s, mut r: photocraft_io::ImportResult| { + // File Handling's ACEScg option, before the colour prompt looks at the profile. + crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); + crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); // Ask When Opening: held back until the prompt is answered (`take_held_open`). A // developed raw has no embedded profile to ask about. let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); @@ -512,10 +572,12 @@ impl Session { return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question})); } let (index, color) = s.open_document(r.document, None); + // Persisted IDs can collide: report the identity the session actually admitted. + let id = s.documents().get(index).ok_or(EngineError::NoDocument)?.doc.id; if let Some(st) = s.active_mut() { st.source_read_only = r.source_read_only; } - Ok(json!({"document": index, "name": name_a, "warnings": r.warnings, "color": color})) + Ok(json!({"document": index, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) }, ) } @@ -694,6 +756,7 @@ impl Session { gate(id, ¶ms)?; } let _authorization = AuthorizationScope::enter(self.authorize); + let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); // Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and // run as a job like that filter, so a recorded step replays the same filter anywhere // (#3089), as Transform Again records its transform (#1440). With no filter yet it falls @@ -714,15 +777,37 @@ impl Session { } // Pixel commands follow the Channels panel target unless the caller names one. let run_params = self.admit(id, spec, ¶ms)?; + // This UI-only disclosure preference applies to both `execute` and `start`: + // desktop UI normally calls `start`, even for synchronous Layer Style commands. + // The layer the command will change, named as the command accepts it (id or name), else the + // active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands. + // Transfer Effects changes its destination (`to`), not the layer it takes the effects from. + let new_fx_target = if id.starts_with("layer.layerStyle.") { + let target = if id == "layer.layerStyle.transferEffects" { + crate::commands::layer_param(self, &json!({"layer": params.get("to").cloned().unwrap_or(Value::Null)})) + } else { + crate::commands::layer_param(self, ¶ms) + } + .ok(); + self.active().and_then(|st| { + let target = target?; + (st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target)) + }) + } else { + None + }; + let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true); self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); self.jobs.spawn = background; // Commands this one runs on its own behalf journal after this mark (#3067). let nested_from = self.journal.len(); + let pre_selected = if self.actions.recording.is_some() { self.active().map(|st| st.selected_layers()) } else { None }; // A replayable command is recorded as one step, so recording pauses while it runs: an // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let before = self.active().map(|st| st.doc.clone()); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -745,17 +830,46 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; + job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.pre_selected = pre_selected.clone(); let jid = job.id; self.jobs.running.push(job); Ok(Started::Job(jid)) } (Ok(v), None) => { - self.after_command(id, params, spec.journal, Some(nested_from)); + if let Some(before) = before { + self.add_relayout_damage(&before); + } + if let Some((doc_id, layer_id)) = new_fx_target + && let Some(st) = self.active_mut() + && st.doc.id == doc_id + && st.doc.layer(layer_id).is_some_and(|l| !l.effects.items.is_empty()) + { + if expand_new_fx { + st.fx_collapsed.retain(|id| *id != layer_id); + } else if !st.fx_collapsed.contains(&layer_id) { + st.fx_collapsed.push(layer_id); + } + } + self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref()); Ok(Started::Done(v)) } } } + /// Grows the damage a command reported for the pixels it changed by what effects laid out + /// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a + /// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out + /// again over the whole grown layer (#4053). `before` is the active document before. + fn add_relayout_damage(&mut self, before: &Arc) { + let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && !Arc::ptr_eq(&st.doc, before)) else { return }; + let Some(d) = st.last_damage.filter(|d| !d.is_empty()) else { return }; + let extra = photocraft_compose::relayout_damage(before, &st.doc); + if !extra.is_empty() { + st.last_damage = Some(d.union(&extra)); + } + } + /// A command that failed on a layer id the document doesn't have says which ids it does have /// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent /// should not need `document.inspect` to recover. @@ -775,12 +889,12 @@ impl Session { /// dropped, so a recorded action replays the command once, not its steps as well (#3067). /// Edit › Transform › Again (and Again on a Copy) is not replayable and keeps the concrete /// steps it ran instead. - fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option) { + fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option, pre_selected: Option<&[LayerId]>) { // A layer-mask view ends when another layer becomes active (#196). if let Some(st) = self.active_mut() { crate::mask_view_cmds::fix(st); } - crate::edit_menu_cmds::after_command(self, id); + crate::edit_menu_cmds::after_command(self, id, ¶ms); crate::automate_cmds::after_command(self, id); self.sync_preset_store(); if !journal { @@ -790,8 +904,12 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = match id { + _ if self.actions.recording.is_none() => params, + "layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)), + "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), + _ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected), + }; self.journal.push((id.to_string(), params)); } } @@ -813,7 +931,13 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, recorded_by, pre_selected, .. } = job; + // A recording started after the job (#7289) keeps what it has so far, and the job's step + // goes into the journal past its start, so that recording doesn't get it. + let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take); + if foreign { + crate::actions_cmds::flush_recording(self)?; + } let prev_active = self.active; let target = match doc { Some(d) => { @@ -828,6 +952,19 @@ impl Session { } None => None, }; + // Selecting a layer doesn't change the document, so the snapshot check above lets it + // through. Apply against the layers the job started on: the colour restriction and any + // `active`-based edit land on them (#7366). The user's selection is put back below. + let user_layers = match (target, layers) { + (Some(i), Some(started)) => self.docs.get_mut(i).and_then(|st| { + let now = LayerSelection::of(st); + (now != *started).then(|| { + started.clone().set(st); + now + }) + }), + _ => None, + }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; // Apply-time hooks can dispatch commands too. Keep the same single-step recording @@ -848,7 +985,19 @@ impl Session { // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. if r.is_ok() { - self.after_command(command, params.clone(), *journal, Some(nested_from)); + self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref()); + if foreign && let Some((start, _)) = self.actions.recording.as_mut() { + *start = self.journal.len(); + } + } + // Give the user back the layers they selected while the job ran, unless the job chose a + // selection itself (a new layer, say) or one of their layers is gone. + if let (Some(started), Some(user), Some(st)) = (layers, user_layers, target.and_then(|i| self.docs.get_mut(i))) { + let kept = LayerSelection::of(st) == *started; + let exist = user.active.iter().chain(&user.selected).all(|id| st.doc.layer(*id).is_some()); + if (r.is_err() || kept) && exist { + user.set(st); + } } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 2bcdda76fc..8ae8d904d3 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -94,6 +94,36 @@ fn free_transform_runs_as_a_job_with_the_inline_result() { assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); } +#[test] +fn warp_runs_as_a_job_with_the_inline_result() { + let arc = json!({"style": "arc", "bend": 30}); + let mut inline = session(300, 200); + inline.execute("edit.transform.warp", arc.clone()).unwrap(); + + let mut s = session(300, 200); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform.warp", arc).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(e.label, "Warp"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); +} + +#[test] +fn a_cancelled_warp_leaves_the_document_unchanged() { + let mut s = session(1600, 1200); + let doc_before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform.warp", json!({"style": "flag", "bend": 40})).unwrap()); + assert!(s.cancel_job(id)); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + s.join_cancelled_jobs(); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &doc_before), "the document snapshot was not replaced"); + assert_eq!(st.history.past_len(), steps); +} + #[test] fn cancel_leaves_the_document_byte_identical() { let mut s = session(1600, 1200); @@ -322,6 +352,56 @@ fn background_job_finishes_bookkeeping_on_edited_document_after_tab_switch() { assert!(s.is_enabled("edit.fade"), "Fade must be available on the document that was edited"); } +/// #7382: a background filter on a targeted alpha channel or the Quick Mask, finished while another +/// document is shown, leaves Fade on that channel of the edited document: at 0 % it restores the +/// channel exactly and leaves the layer alone. +#[test] +fn background_filter_on_a_channel_fades_that_channel_after_a_tab_switch() { + use crate::edit_menu_cmds::FadeTarget; + for quick_mask in [false, true] { + let mut s = session(32, 24); + let edited_doc = s.active().unwrap().doc.id; + if quick_mask { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + } else { + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + } + // A pattern for the blur to change. + s.edit("pattern", |doc, _| { + let c = if quick_mask { doc.quick_mask.as_mut() } else { doc.channels.first_mut() }; + let c = c.ok_or_else(|| EngineError::Other("no channel".into()))?; + c.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 24), &[0.0]); + c.surface.fill_rect(photocraft_geom::Rect::new(8, 6, 20, 16), &[1.0]); + Ok(()) + }) + .unwrap(); + let channel = |s: &Session, d: usize| { + let doc = &s.documents()[d].doc; + let c = if quick_mask { doc.quick_mask.as_ref() } else { doc.channels.first() }; + let c = c.unwrap(); + (0..24).flat_map(|y| (0..32).map(move |x| (x, y))).map(|(x, y)| c.surface.pixel(x, y)[0]).collect::>() + }; + let layer = |s: &Session| { + let st = &s.documents()[0]; + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(5, 5) + }; + let (pattern, pixels) = (channel(&s, 0), layer(&s)); + let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let event = wait_event(&mut s, id); + assert!(matches!(event.outcome, JobOutcome::Done(_)), "{event:?}"); + assert_ne!(channel(&s, 0), pattern, "quick mask {quick_mask}: the blur changed the channel"); + let fade = s.edit_state.fade.clone().unwrap(); + assert_eq!(fade.doc, edited_doc); + assert_eq!(fade.target, if quick_mask { FadeTarget::QuickMask } else { FadeTarget::Channel(0) }); + assert!(s.set_active(0)); + s.execute("edit.fade", json!({"opacity": 0})).unwrap(); + assert_eq!(channel(&s, 0), pattern, "quick mask {quick_mask}: Fade at 0 % restores the channel"); + assert_eq!(layer(&s), pixels, "quick mask {quick_mask}: the layer is left alone"); + } +} + #[test] fn closing_the_document_cancels_its_job() { let mut s = session(1600, 1200); @@ -605,3 +685,45 @@ fn an_inline_job_ctx_lets_another_thread_cancel_inline_work() { s.set_inline_job_ctx(None); s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); } + +/// The command ids an action holds. +fn action_steps(s: &mut Session, name: &str) -> Vec { + let a = s.execute("actions.get", json!({"action": name})).unwrap(); + a["steps"].as_array().unwrap().iter().map(|step| step[0].as_str().unwrap().to_string()).collect() +} + +/// A job's result is recorded only by the recording it was started in: one started later keeps +/// its own steps around it but doesn't pick up the older edit (#7289). +#[test] +fn a_job_lands_in_the_recording_it_was_started_in_only() { + // Started while recording A, applied after A stopped and B started. + let mut s = session(40, 30); + s.execute("actions.record", json!({"name": "A"})).unwrap(); + let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap()); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.record", json!({"name": "B"})).unwrap(); + // B's own steps before and after the result lands stay in B (a new document, so the job's + // document is unchanged and its result applies). + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.wait_job(id).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(action_steps(&mut s, "A").is_empty()); + assert_eq!(action_steps(&mut s, "B"), ["file.new", "image.adjustments.desaturate"]); + + // Started before any recording, applied while B records. + let mut s = session(40, 30); + let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap()); + s.execute("actions.record", json!({"name": "B"})).unwrap(); + s.wait_job(id).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(action_steps(&mut s, "B").is_empty()); + + // Started and applied while A records: A has it, as before. + let mut s = session(40, 30); + s.execute("actions.record", json!({"name": "A"})).unwrap(); + let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap()); + s.wait_job(id).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(action_steps(&mut s, "A"), ["filter.blur.gaussianBlur"]); +} diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..9165990122 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -168,11 +168,12 @@ fn apply_mask(s: &mut Session, p: &Value) -> Result { fn mask_from_transparency(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("From Transparency", |doc, _| { + let depth = doc.depth; let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; // Masks are attached to layer coordinates, including pixels temporarily off-canvas. // Clipping to document bounds loses their alpha permanently when moved back (#3043). let r = surf.content_bounds(); - let mut mask = LayerMask::hide_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::hide_all(), depth)?; if !r.is_empty() { let mut px = read_rgba(surf, r); let alpha: Vec = px.iter().map(|q| q[3]).collect(); @@ -183,6 +184,7 @@ fn mask_from_transparency(s: &mut Session, p: &Value) -> Result { } write_rgba(surf, r, &px); } + crate::extra_cmds::background_to_layer_for_mask(doc, id); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); Ok(Value::Null) }) @@ -198,7 +200,7 @@ fn hide_selection(s: &mut Session, p: &Value) -> Result { for x in &mut v { *x = 1.0 - *x; } - let mut mask = LayerMask::reveal_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::reveal_all(), doc.depth)?; mask.surface.write_region(bounds, &v); mask.surface.prune(); for id in ids { @@ -235,10 +237,11 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { let region = region.ok_or_else(|| other("no objects were found"))?; let bbox = region.bbox; s.edit("Mask All Objects", |doc, _| { - let mut mask = LayerMask::hide_all(); + let mut mask = crate::commands::mask_at_depth(LayerMask::hide_all(), doc.depth)?; let v: Vec = region.mask.iter().map(|m| f32::from(*m) / 255.0).collect(); mask.surface.write_region(bbox, &v); mask.surface.prune(); + crate::extra_cmds::background_to_layer_for_mask(doc, id); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask); Ok(()) })?; @@ -373,30 +376,56 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { let SmartSource::Linked { path } = &sm.source else { return Err(other("embedded smart objects have no file to reveal")); }; - reveal(path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) + // The path is stored in the document: shown only when it would be followed. + let file = + crate::doc_path::resolve_in(path, d.path.as_deref().map(std::path::Path::new)).map_err(|why| crate::doc_path::refusal("the linked file", path, why))?; + reveal(&file.to_string_lossy(), p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) } /// Show `path` in the platform file manager ([`reveal_command`] + [`spawn`]); `dry_run` answers /// the program and arguments instead (tests). Shared by the smart-object command and the document /// tab's Reveal in Finder (UI-217-6). pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { - let (program, args) = reveal_command(path); + let (program, args) = reveal_command(path)?; if dry_run { return Ok(json!({"program": program, "args": args})); } + ensure_folder(program, &args)?; spawn(program, &args)?; Ok(json!({"path": path})) } +/// `xdg-open` opens whatever it is given in its default application, so it only gets a folder: a +/// linked path such as `tool.jar/a.png` would otherwise launch `tool.jar` instead of showing it. +fn ensure_folder(program: &str, args: &[String]) -> Result<()> { + if program != "xdg-open" { + return Ok(()); + } + match args.first() { + Some(dir) if std::path::Path::new(dir).is_dir() => Ok(()), + Some(dir) => Err(other(format!("{dir} is not a folder, so it can't be shown in the file manager"))), + None => Err(other("no folder to show in the file manager")), + } +} + /// The file-manager invocation that selects `path` on this platform. -pub fn reveal_command(path: &str) -> (&'static str, Vec) { - if cfg!(target_os = "macos") { - ("open", vec!["-R".into(), path.into()]) - } else if cfg!(target_os = "windows") { - ("explorer", vec![format!("/select,{path}")]) - } else { - let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); - ("xdg-open", vec![parent]) +pub fn reveal_command(path: &str) -> Result<(&'static str, Vec)> { + reveal_command_on(std::env::consts::OS, path) +} + +/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value). +fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec)> { + match os { + // `--` ends the options: a path starting with `-` is not read as one. + "macos" => Ok(("open", vec!["-R".into(), "--".into(), path.into()])), + // Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path + // would select something else. + "windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))), + "windows" => Ok(("explorer", vec![format!("/select,{path}")])), + _ => { + let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); + Ok(("xdg-open", vec![parent])) + } } } @@ -876,11 +905,16 @@ fn export_layer(s: &mut Session, p: &Value, cmd: &str, png_only: bool) -> Result return Err(bad(cmd, "Quick Export as PNG writes .png files")); } let scale = num(p, "scale", 100.0).clamp(1.0, 1000.0); + // Export As › Resample; checked even at 100 % so a typo never goes unnoticed. + let resample = match p.get("resample") { + None | Some(Value::Null) => crate::export_preset_cmds::DEFAULT_RESAMPLE, + Some(v) => crate::export_preset_cmds::export_resample(v.as_str().ok_or_else(|| bad(cmd, "`resample` must be a string"))?).map_err(|e| bad(cmd, e))?, + }; if (scale - 100.0).abs() > 1e-3 { let mut tmp = Session::new(); tmp.add_document(ldoc, None); let w = (tmp.active().map_or(1, |d| d.doc.size.width) as f32 * scale / 100.0).round().max(1.0); - tmp.execute("image.imageSize", json!({"width": w}))?; + tmp.execute("image.imageSize", json!({"width": w, "resample": resample}))?; ldoc = (*tmp.active().ok_or(EngineError::NoDocument)?.doc).clone(); } let out = photocraft_io::export(&ldoc, path, &Default::default()).map_err(|e| other(e.to_string()))?; @@ -994,7 +1028,7 @@ pub fn specs() -> Vec { "layer.exportAs", "Export As…", ["Layer"], - r##"{"layer":id?,"path":text,"scale":1..1000=100} (format from the path's extension)"##, + r##"{"layer":id?,"path":text,"scale":1..1000=100,"resample":"bicubicAutomatic|bicubic|bicubicSmoother|bicubicSharper|bilinear|nearest"="bicubicAutomatic"} (format from the path's extension; the document is not resampled)"##, has_layer, |s, p| export_layer(s, p, "layer.exportAs", false) ), diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 46688500b1..4f5c6dc12f 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -385,6 +385,38 @@ fn export_layer_trims_to_its_pixels() { let _ = std::fs::remove_dir_all(&dir); } +/// Layer › Export As › Resample: Nearest Neighbor turns each layer pixel into an exact 2 × 2 +/// block at 200 %, an unknown method is an error (never a silent Bicubic), and the document is +/// left as it was. +#[test] +fn export_layer_resamples_with_the_chosen_method() { + let dir = std::env::temp_dir().join(format!("pc-layer-export-resample-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for depth in [8, 16] { + let mut s = session(depth, "rgb"); + paint(&mut s, |x, y| if (x + y) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); + let before = s.active().unwrap().doc.clone(); + let path = dir.join(format!("nearest-{depth}.png")); + let path = path.to_string_lossy(); + let r = s.execute("layer.exportAs", json!({"path": path, "scale": 200, "resample": "nearest"})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(80), Some(60))); + let out = photocraft_io::import("x.png", &std::fs::read(&*path).unwrap()).unwrap().document; + let surf = out.layers[0].surface().unwrap(); + for y in 0..60 { + for x in 0..80 { + let want = if (x / 2 + y / 2) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }; + assert_eq!(surf.rgba(x, y), want, "{depth}-bit pixel {x},{y}"); + } + } + for bad in [json!("lanczos"), json!("Nearest"), json!(""), json!(2)] { + let err = s.execute("layer.exportAs", json!({"path": path, "scale": 200, "resample": bad})).unwrap_err().to_string(); + assert!(err.contains("resample"), "{depth}-bit {bad}: {err}"); + } + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before), "{depth}-bit: exporting never edits the document"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn stack_modes_render_from_the_nested_layers() { let mut s = session(8, "rgb"); @@ -465,16 +497,13 @@ fn stack_mode_rejects_sources_without_visible_layers() { #[test] fn reveal_in_finder_dry_run_and_enabled() { + let link = std::env::temp_dir().join("pics").join("a.png").to_string_lossy().into_owned(); let mut s = session(8, "rgb"); assert!(!s.is_enabled("layer.smartObjects.revealInFinder")); s.edit("linked", |doc, active| { let mut l = Layer::new( "linked", - LayerContent::Smart(photocraft_doc::SmartObject::new( - SmartSource::Linked { path: "/tmp/pics/a.png".into() }, - photocraft_geom::Affine::IDENTITY, - None, - )), + LayerContent::Smart(photocraft_doc::SmartObject::new(SmartSource::Linked { path: link.clone() }, photocraft_geom::Affine::IDENTITY, None)), ); l.visible = true; *active = Some(doc.insert_above(*active, l)); @@ -483,11 +512,61 @@ fn reveal_in_finder_dry_run_and_enabled() { .unwrap(); assert!(s.is_enabled("layer.smartObjects.revealInFinder")); let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap(); - let (prog, args) = reveal_command("/tmp/pics/a.png"); + let (prog, args) = reveal_command(&link).unwrap(); assert_eq!(r["program"], json!(prog)); assert_eq!(r["args"], json!(args)); } +#[test] +fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { + let mut s = session(8, "rgb"); + let folder = std::env::temp_dir().join("pics"); + s.active_mut().unwrap().path = Some(folder.join("doc.pcraft").to_string_lossy().into_owned()); + let mut link = |path: &str| { + s.edit("linked", |doc, active| { + let so = photocraft_doc::SmartObject::new(SmartSource::Linked { path: path.into() }, photocraft_geom::Affine::IDENTITY, None); + *active = Some(doc.insert_above(*active, Layer::new("linked", LayerContent::Smart(so)))); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).map_err(|e| e.to_string()) + }; + let err = link(r"\\192.0.2.1\share\x.png").unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + // A relative link is shown next to the document. + let r = link("a.png").unwrap(); + assert_eq!(r["args"], json!(reveal_command(&folder.join("a.png").to_string_lossy()).unwrap().1)); + // The document's own file is shown wherever it is, a network share included. + s.active_mut().unwrap().path = Some(r"\\192.0.2.1\share\doc.psd".into()); + s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); + + assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()])); + assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()])); + for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] { + assert!(reveal_command_on("windows", path).is_err(), "{path}"); + } +} + +/// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a +/// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent. +#[test] +fn reveal_on_linux_never_hands_xdg_open_a_file() { + let dir = std::env::temp_dir().join(format!("photocraft-reveal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("tool.jar"); + std::fs::write(&file, b"not a folder").unwrap(); + let (program, args) = reveal_command_on("linux", &file.join("a.png").to_string_lossy()).unwrap(); + assert_eq!(args, vec![file.to_string_lossy().into_owned()]); + let err = ensure_folder(program, &args).unwrap_err(); + assert!(err.to_string().contains("is not a folder"), "{err}"); + let (program, args) = reveal_command_on("linux", &dir.join("a.png").to_string_lossy()).unwrap(); + ensure_folder(program, &args).unwrap(); + assert!(ensure_folder("xdg-open", &[]).is_err()); + // Finder and Explorer select the file itself, whatever it is. + ensure_folder("open", &["-R".into(), "--".into(), file.to_string_lossy().into_owned()]).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); @@ -505,6 +584,59 @@ fn mask_all_objects_masks_the_subject() { } } +fn subject(x: i32, y: i32) -> [f32; 4] { + let inside = (((x - 20) * (x - 20) + (y - 15) * (y - 15)) as f32).sqrt() < 8.0; + if inside { [0.9, 0.2, 0.1, 1.0] } else { [0.95, 0.95, 0.95, 1.0] } +} + +#[test] +fn from_transparency_and_mask_all_objects_turn_the_background_into_a_layer() { + for cmd in ["layer.layerMask.fromTransparency", "layer.maskAllObjects", "layer.layerMask.hideSelection"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + assert!(doc(&s).is_background(id)); + paint(&mut s, subject); + if cmd.ends_with("hideSelection") { + select_left_half(&mut s); + } + s.execute(cmd, json!({})).unwrap(); + assert!(!doc(&s).is_background(id), "{cmd}: the Background became a layer"); + assert_eq!(active(&s).name, "Layer 0", "{cmd}"); + assert!(active(&s).mask.is_some(), "{cmd}"); + } +} + +#[test] +fn layer_masks_are_built_at_the_document_depth() { + use photocraft_color::SampleType; + for (depth, sample, tol) in [(8, SampleType::U8, 1.0 / 255.0), (16, SampleType::U16, 1.0 / 65535.0), (32, SampleType::F32, 1e-6)] { + let mut s = session(depth, "rgb"); + paint(&mut s, |_, _| [0.2, 0.4, 0.6, 0.5003]); + s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "From Transparency at {depth}"); + assert!((m.value(5, 5) - 0.5003).abs() < tol, "From Transparency at {depth}: {}", m.value(5, 5)); + assert_eq!(m.value(-50, -50), 0.0, "outside the layer stays hidden at {depth}"); + + let mut s = session(depth, "rgb"); + paint(&mut s, |_, _| [0.2, 0.4, 0.6, 1.0]); + select_left_half(&mut s); + s.execute("layer.layerMask.hideSelection", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "Hide Selection at {depth}"); + assert_eq!((m.value(5, 5), m.value(30, 5), m.value(500, 500)), (0.0, 1.0, 1.0), "Hide Selection at {depth}"); + + let mut s = session(depth, "rgb"); + paint(&mut s, subject); + s.execute("layer.maskAllObjects", json!({})).unwrap(); + let m = active(&s).mask.as_ref().unwrap(); + assert_eq!(m.surface.format().sample, sample, "Mask All Objects at {depth}"); + assert!(m.value(20, 15) > m.value(1, 1), "Mask All Objects at {depth}"); + assert_eq!(m.value(500, 500), 0.0, "Mask All Objects at {depth}"); + } +} + #[test] fn layer_masks_in_gray_cmyk_lab() { for mode in ["gray", "cmyk", "lab"] { diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..83d4e815a2 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -6,7 +6,7 @@ //! changing it never adds a history step. Every structural change below is one history step. use photocraft_color::PixelFormat; -use photocraft_doc::{Document, Layer, LayerContent, LayerId}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, VideoData}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -118,6 +118,31 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value { p } +/// Normalise parameters when recording an action step. +/// +/// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer` +/// parameter references the document's sole selected layer, normalises it to the target form +/// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when +/// replayed or exported to `.atn` (#7248). +pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Option<&[LayerId]>) -> Value { + if id == "layer.select" { + return recorded_selection(s, p); + } + let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let is_sole_selected = match pre_selected { + Some(sel) => sel == [layer_id], + None => s.active().is_some_and(|st| st.selected_layers().as_slice() == [layer_id]), + }; + if is_sole_selected { + let mut p = p; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + } + return p; + } + p +} + fn selection_target(s: &Session, p: &Value) -> Result { let Some(name) = p.get("name") else { if p.get("occurrence").is_some() { @@ -361,6 +386,10 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } Some(other) => return Err(bad("layer.translate", format!("unknown units `{other}` (px|pt)"))), }; + let carry = match p.get("selection") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("layer.translate", "`selection` must be a boolean"))?, + }; if dx == 0 && dy == 0 { return Ok(Value::Null); } @@ -396,6 +425,11 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) { crate::artboard_cmds::fit_canvas(doc); } + // The Move tool carries the selection along when it moves several layers whole, as + // Photoshop does (#7538): one step, so Undo takes both back. + if carry { + doc.selection = doc.selection.as_ref().map(|sel| shift_surface(sel, dx, dy)); + } Ok(ids) })?; note_damage(s, &before, &ids); @@ -463,26 +497,7 @@ fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { *c = translate_surface(c, dx, dy); } } - LayerContent::Shape(sh) => { - // The rendered shape is cut at the canvas: one that reaches past it renders again. - let canvas = doc.bounds(); - let was_inside = sh.cache.as_ref().is_some_and(|c| { - let b = photocraft_compose::bounds::content_bounds(c); - b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) - }); - crate::vector_cmds::transform_shape(sh, &a); - let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); - let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { - (x0 - stroke_pad) > canvas.x0 as f64 - && (y0 - stroke_pad) > canvas.y0 as f64 - && (x1 + stroke_pad) < canvas.x1 as f64 - && (y1 + stroke_pad) < canvas.y1 as f64 - }); - match &mut sh.cache { - Some(c) if was_inside && stays_inside => *c = translate_surface(c, dx, dy), - _ => crate::vector_cmds::refresh_shape(doc, sh), - } - } + LayerContent::Shape(sh) => crate::vector_cmds::shift_shape(doc, sh, f64::from(dx), f64::from(dy)), LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy), LayerContent::Group(g) => { if let Some(ab) = &mut g.artboard { @@ -502,7 +517,8 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op let canvas = d.bounds(); for id in ids { // A layer gone from one side (never for a move) changes who knows what. - let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?; + let (siblings, k, outer) = siblings_of(d, *id)?; + let b = photocraft_compose::change_bounds_in(siblings, k, outer, canvas)?; if !b.is_empty() { out = if out.is_empty() { b } else { out.union(&b) }; } @@ -511,6 +527,17 @@ pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Op Some(out) } +/// The sibling list holding layer `id` in `doc` (bottom→top), its index there, and the list +/// holding its parent group with the group's index (`None` at the top level). +type Siblings<'d> = (&'d [Layer], usize, Option<(&'d [Layer], usize)>); +fn siblings_of(doc: &Document, id: LayerId) -> Option> { + let path = doc.path_of(id)?; + let (&k, parent) = path.split_last()?; + let Some((&g, grand)) = parent.split_last() else { return Some((doc.layers.as_slice(), k, None)) }; + let outer = if grand.is_empty() { doc.layers.as_slice() } else { doc.layer_at(grand)?.children()? }; + Some((outer.get(g)?.children()?, k, Some((outer, g)))) +} + /// Report [`layers_damage`] as the active document's last change, so the canvas recomposites /// only that (grown by the effect reach around it) instead of everything. pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) { @@ -610,7 +637,7 @@ fn alpha_channels_equal(a: &photocraft_doc::AlphaChannel, b: &photocraft_doc::Al /// compared here: the caller walks into them, so an edit inside a group damages only the /// edited child's extent, not the whole group's. The destructure is exhaustive, so a new /// `Layer` field fails to compile until it is classified: compared, or explicitly ignored -/// (name, locks, label, `psd_blocks`, `psd_id`, link group — none of them composite; video +/// (name, locks, label, `psd_blocks`, `psd_id`, link group, XMP — none of them composite; video /// bails out earlier in the walk). fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool { let Layer { @@ -635,6 +662,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool { blend_if, advanced, video: _, + xmp: _, } = a; *id == b.id && *visible == b.visible @@ -728,21 +756,107 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool { /// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer. /// Conservative (`false`) past a nesting depth no real document reaches. pub fn same_pixels(a: &Document, b: &Document) -> bool { - fn walk(xs: &[Layer], ys: &[Layer], depth: usize) -> bool { + pixels_equal_with(a, b, |_, _| false) +} + +/// [`same_pixels`], with video layers equal when `video_equal` says their frame stacks are. +fn pixels_equal_with(a: &Document, b: &Document, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool { + fn walk(xs: &[Layer], ys: &[Layer], depth: usize, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool { depth < 256 && xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| { - x.video.is_none() - && y.video.is_none() - && layer_pixels_equal(x, y) + layer_pixels_equal(x, y) + && match (&x.video, &y.video) { + (None, None) => true, + (Some(vx), Some(vy)) => video_equal(vx, vy), + _ => false, + } && match (x.children(), y.children()) { - (Some(xc), Some(yc)) => walk(xc, yc, depth + 1), + (Some(xc), Some(yc)) => walk(xc, yc, depth + 1, video_equal), (None, None) => true, _ => false, } }) } - doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0) + doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0, video_equal) +} + +/// Whether two video layers hold the same frames: the same settings and stack shape +/// (`VideoData`'s own, shallow equality) and the same tile pointers in every frame, painted and +/// original. Pointer compares only, like [`surfaces_equal`]. +fn video_frames_equal(a: &VideoData, b: &VideoData) -> bool { + let frames = |x: &[photocraft_raster::Surface], y: &[photocraft_raster::Surface]| x.len() == y.len() && x.iter().zip(y).all(|(f, g)| surfaces_equal(f, g)); + *a == *b + && frames(&a.frames, &b.frames) + && match (&a.original_frames, &b.original_frames) { + (Some(x), Some(y)) => frames(x, y), + (None, None) => true, + _ => false, + } +} + +/// Whether `a` and `b` differ at most in their selection (#6865): a selection step, which must +/// not mark a clean document modified. Pixels compare by tile pointer (as in [`same_pixels`], +/// plus every frame of a video layer, whose own equality is shallow); every other field compares +/// structurally, where copy-on-write `Arc`s of `Eq` data short-circuit on pointer identity. The +/// destructure is exhaustive, so a new `Document` field fails to compile until it is classified. +pub fn selection_only_change(a: &Document, b: &Document) -> bool { + let Document { + id, + name, + size: _, + resolution_dpi: _, + mode: _, + depth: _, + icc_profile: _, + layers, + channels: _, + guides, + ruler_origin, + selection: _, + metadata, + global_light: _, + paths, + work_path, + clipping_path, + quick_mask: _, + quick_mask_unselected, + patterns: _, + color_table: _, + duotone: _, + layer_comps, + last_applied_comp, + last_document_state, + measurement, + notes, + text_styles, + slices, + variables, + timeline, + file_encoding, + } = a; + // The ignored fields above are the pixel ones `pixels_equal_with` already compares. + pixels_equal_with(a, b, video_frames_equal) + && *id == b.id + && *name == b.name + && *layers == b.layers + && *guides == b.guides + && *ruler_origin == b.ruler_origin + && *metadata == b.metadata + && *paths == b.paths + && *work_path == b.work_path + && *clipping_path == b.clipping_path + && *quick_mask_unselected == b.quick_mask_unselected + && *layer_comps == b.layer_comps + && *last_applied_comp == b.last_applied_comp + && *last_document_state == b.last_document_state + && *measurement == b.measurement + && *notes == b.notes + && *text_styles == b.text_styles + && *slices == b.slices + && *variables == b.variables + && *timeline == b.timeline + && *file_encoding == b.file_encoding } /// Pixels a single history step can have changed between `a` and `b` (the documents on either @@ -761,11 +875,13 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { if !doc_pixels_equal(a, b) { return None; } - fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, out: &mut Rect) -> bool { + /// The list holding a sibling list's parent group, and the group's index there. + type Outer<'d> = Option<(&'d [Layer], usize)>; + fn walk(xs: &[Layer], ys: &[Layer], (ox, oy): (Outer, Outer), a: &Document, b: &Document, out: &mut Rect) -> bool { // Pair the layers by id, stepping over layers only one side has: one that draws nothing // changes no pixels (a new empty layer or group, #1771), any other only where it draws. - let one_sided = |layers: &[Layer], k: usize, canvas: Rect, out: &mut Rect| { - let Some(r) = one_sided_bounds(layers, k, canvas) else { return false }; + let one_sided = |layers: &[Layer], k: usize, outer: Outer, canvas: Rect, out: &mut Rect| { + let Some(r) = one_sided_bounds(layers, k, outer, canvas) else { return false }; if !r.is_empty() { *out = if out.is_empty() { r } else { out.union(&r) }; } @@ -785,14 +901,14 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { continue; } (_, Some(y)) if xs.iter().all(|x| x.id != y.id) => { - if !one_sided(ys, j, b.bounds(), out) { + if !one_sided(ys, j, oy, b.bounds(), out) { return false; } j += 1; continue; } (Some(x), _) if ys.iter().all(|y| y.id != x.id) => { - if !one_sided(xs, i, a.bounds(), out) { + if !one_sided(xs, i, ox, a.bounds(), out) { return false; } i += 1; @@ -801,6 +917,7 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { // Both sides have the layer, in another order. _ => return false, }; + let (xk, yk) = (i, j); i += 1; j += 1; if x.video.is_some() || y.video.is_some() { @@ -813,14 +930,17 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } if !layer_pixels_equal(x, y) { // Both sides' extents: a move changes pixels where it left and where it landed. - let (Some(bx), Some(by)) = (photocraft_compose::change_bounds(x, a.bounds()), photocraft_compose::change_bounds(y, b.bounds())) else { + // A clipped adjustment reaches as far as its base (#6535). + let (Some(bx), Some(by)) = + (photocraft_compose::change_bounds_in(xs, xk, ox, a.bounds()), photocraft_compose::change_bounds_in(ys, yk, oy, b.bounds())) + else { return false; }; *out = if out.is_empty() { bx.union(&by) } else { out.union(&bx).union(&by) }; } match (x.children(), y.children()) { (Some(xc), Some(yc)) => { - if !walk(xc, yc, a, b, out) { + if !walk(xc, yc, (Some((xs, xk)), Some((ys, yk))), a, b, out) { return false; } } @@ -830,15 +950,16 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } } let mut out = Rect::EMPTY; - if walk(&a.layers, &b.layers, a, b, &mut out) { Some(out) } else { None } + if walk(&a.layers, &b.layers, (None, None), a, b, &mut out) { Some(out) } else { None } } /// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds -/// (`photocraft_compose::change_bounds`: not for an adjustment, or a pass-through group holding -/// one), provided it isn't the clipping base of clipped layers above it (they would change base), -/// and neither it nor anything inside it is a video layer or excludes channels (both reach -/// outside the layer's bounds, see [`step_damage`]). -fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { +/// (`photocraft_compose::change_bounds_in`: not for an unclipped adjustment, or a pass-through +/// group holding one; a clipped adjustment's are its base's), provided it isn't the clipping base +/// of clipped layers above it (they would change base), and neither it nor anything inside it is +/// a video layer or excludes channels (both reach outside the layer's bounds, see +/// [`step_damage`]). +fn one_sided_bounds(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option { fn contained(l: &Layer, depth: usize) -> bool { depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1))) } @@ -847,7 +968,7 @@ fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { if new_base || !contained(l, 0) { return None; } - photocraft_compose::change_bounds(l, canvas) + photocraft_compose::change_bounds_in(layers, k, outer, canvas) } /// Whether `layers[k]` draws nothing wherever it sits, so adding or removing it leaves the @@ -885,9 +1006,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -965,8 +1086,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { // Photoshop: with an active selection, the layers align to its bounds (Layer › Align // Layers to Selection), however many are selected. (Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(), - // One layer with no selection aligns to the canvas (otherwise it would align to itself). - (None, 1) if to == "auto" => doc.bounds(), + // One layer with no selection aligns to the canvas (otherwise it would align to + // itself), or to the artboard it sits in, as in Photoshop (#7945). + (None, 1) if to == "auto" => items.first().map_or_else(|| doc.bounds(), |(id, _)| board_or_canvas(doc, *id)), _ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)), }, other => return Err(bad(&cmd, format!("`to` must be auto|layers|selection|canvas, not `{other}`"))), @@ -991,6 +1113,12 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { Ok(json!({"moved": moved})) } +/// The bounds a lone layer aligns to: the artboard holding it, else the canvas. An artboard +/// itself aligns to the canvas. +fn board_or_canvas(doc: &Document, id: LayerId) -> Rect { + doc.artboard_of(id).filter(|board| *board != id).and_then(|board| doc.layer(board)?.artboard().map(|a| a.rect)).unwrap_or_else(|| doc.bounds()) +} + /// One move per layer (first wins), skipping layers nested in another moved layer. fn dedup_moves(doc: &Document, moves: Vec<(LayerId, i32, i32)>) -> Vec<(LayerId, i32, i32)> { let mut out: Vec<(LayerId, i32, i32)> = Vec::new(); @@ -1057,6 +1185,84 @@ fn distribute(s: &mut Session, kind: &str) -> Result { /// Reorder one or multiple layers to a single destination in one undoable edit. /// /// Explicit `layers` are ordered according to the source document, not caller ordering. +/// A transient snapshot for the Layers panel's "Preview on canvas when reordering layers". +/// Uses the same source ordering and insertion semantics as `move_to`, but never edits history. +/// Invalid/self-referential hover targets return None (the committed image stays visible). +pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option { + let target = LayerId(p.get("target")?.as_u64()?); + let position = p.get("position")?.as_str()?; + if !matches!(position, "above" | "below" | "into") { + return None; + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let ids = if let Some(ids) = p.get("layers") { + ids.as_array()?.iter().map(|v| v.as_u64().map(LayerId)).collect::>>()? + } else { + vec![LayerId(p.get("layer")?.as_u64()?)] + }; + if ids.is_empty() || doc.layer(target).is_none() { + return None; + } + let moved_ids = top_level(doc, &ids); + if moved_ids.is_empty() || moved_ids.iter().any(|id| doc.layer(*id).is_none()) { + return None; + } + if moved_ids.len() == 1 && moved_ids[0] == target && !copy { + return None; + } + let target_path = doc.path_of(target)?; + for id in &moved_ids { + let path = doc.path_of(*id)?; + if target_path.starts_with(&path) && !(copy && *id == target && position != "into") { + return None; + } + } + if position == "into" && !doc.layer(target)?.is_group() { + return None; + } + // The very structural move the drop commits, so the preview shows the same clip groups, + // Background handling and group-depth refusals; a drop `move_to` would refuse has no preview. + let mut preview = doc.clone(); + apply_move(&mut preview, &moved_ids, target, position, copy, add_copy).ok()?; + Some(preview) +} + +/// The document-only part of `move_to`, shared with [`preview_move`] so the Layers panel's hover +/// preview cannot drift from what the drop commits. Moves (or, with `copy`, duplicates) the +/// top-level `moved_ids` to `pos` (`above`, `below` or `into`) `target`, then applies the +/// group-depth cap, the Background-stays-at-the-bottom rule and the clip-group rules +/// (`clip_group::settle`). Returns the ids of the new copies (empty for a move). On `Err` `doc` is +/// half edited: run it inside `Session::edit` or on a scratch copy. +fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &str, copy: bool, add_copy: bool) -> Result> { + const CMD: &str = "layer.moveTo"; + let background = crate::background_cmds::background_id(doc); + let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, moved_ids) }; + let mut moved = Vec::with_capacity(moved_ids.len()); + for &id in moved_ids { + moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); + } + let copies: Vec = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() }; + // Look up the destination AFTER removing every source: its indices may have shifted. + let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; + if pos == "into" { + let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?; + children.extend(moved); + } else { + let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?; + let siblings = if parent.is_empty() { + &mut doc.layers + } else { + doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))? + }; + let at = if pos == "below" { idx } else { idx + 1 }; + siblings.splice(at..at, moved); + } + check_group_depth(doc, "Reorder Layer")?; + crate::background_cmds::keep_background_at_bottom(background, doc)?; + crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false); + Ok(copies) +} + /// Selected descendants of a moved group remain inside their parent; only top-level /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { @@ -1114,32 +1320,9 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } let original_active = st.active_layer; - let background = crate::background_cmds::background_id(doc); + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| { - let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, &moved_ids) }; - let mut moved = Vec::with_capacity(moved_ids.len()); - for &id in &moved_ids { - moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? }); - } - let copies: Vec = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() }; - // Look up the destination AFTER removing every source: its indices may have shifted. - let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; - if pos == "into" { - let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?; - children.extend(moved); - } else { - let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?; - let siblings = if parent.is_empty() { - &mut doc.layers - } else { - doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))? - }; - let at = if pos == "below" { idx } else { idx + 1 }; - siblings.splice(at..at, moved); - } - check_group_depth(doc, "Reorder Layer")?; - crate::background_cmds::keep_background_at_bottom(background, doc)?; - crate::clip_group::settle(doc, if copy { &copies } else { &moved_ids }, &before, false); + let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?; *active = if copy { copies.last().copied() } else if batch.is_some() { @@ -1262,7 +1445,8 @@ 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 group placed where the top-most of them was. Bottom-to-top order is preserved. +/// into a new, collapsed group placed where the top-most of them was. Bottom-to-top order is +/// preserved. pub fn group_layers(s: &mut Session, p: &Value) -> Result { let ids = match crate::commands::explicit_layer(s, p)? { Some(id) => vec![id], @@ -1276,7 +1460,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { let ids = top_level(doc, &ids); let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?; let name = name.unwrap_or_else(|| doc.next_layer_name("Group")); - let gid = doc.insert_above(Some(top), Layer::group(name, vec![])); + // 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 { + g.expanded = false; + } + let gid = doc.insert_above(Some(top), group); let mut children = Vec::with_capacity(ids.len()); for id in &ids { children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?); @@ -1303,32 +1492,54 @@ fn merge_layers(s: &mut Session) -> Result { } let mid = s.edit("Merge Layers", |doc, active| { let ids = top_level(doc, &sel); - let top_id = *ids.last().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; - let top = doc.layer(top_id).ok_or(EngineError::NoLayer(top_id))?.clone(); + let lowest_id = *ids.first().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; + let is_background = doc.is_background(lowest_id); + let mut solo = doc.clone(); solo.layers = ids.iter().filter_map(|id| doc.layer(*id)).filter(|l| l.visible).cloned().collect(); if solo.layers.is_empty() { return Err(EngineError::Other("the selected layers are all hidden".into())); } - let fmt = doc.pixel_format(); - let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); - let mut merged = Layer::raster(top.name.clone(), fmt); - merged.locks = top.locks; - merged.label = top.label; - // Clipped layers of one chain merge into a layer still clipped to their base. Clipped - // layers of different chains don't: the result, at the top one's slot, would clip the - // lower chain's pixels to the upper chain's base. - let base = crate::clip_group::base_of(doc, top_id); - merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); - let mid = merged.id; - // Hidden selected layers are discarded, as in Photoshop. - for id in &ids[..ids.len() - 1] { - doc.remove(*id); + + if is_background { + let base = doc.layer(lowest_id).ok_or(EngineError::NoLayer(lowest_id))?.clone(); + let fmt = doc.pixel_format(); + let mut merged = Layer::raster(base.name.clone(), fmt); + merged.locks = base.locks; + merged.label = base.label; + merged.id = lowest_id; + merged.blend = base.blend; + merged.opacity = 1.0; + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + for id in &ids[1..] { + doc.remove(*id); + } + *doc.layer_mut(lowest_id).ok_or(EngineError::NoLayer(lowest_id))? = merged; + *active = Some(lowest_id); + Ok(lowest_id) + } else { + let top_id = *ids.last().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?; + let top = doc.layer(top_id).ok_or(EngineError::NoLayer(top_id))?.clone(); + let fmt = doc.pixel_format(); + let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); + let mut merged = Layer::raster(top.name.clone(), fmt); + merged.locks = top.locks; + merged.label = top.label; + // Clipped layers of one chain merge into a layer still clipped to their base. Clipped + // layers of different chains don't: the result, at the top one's slot, would clip the + // lower chain's pixels to the upper chain's base. + let base = crate::clip_group::base_of(doc, top_id); + merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); + let mid = merged.id; + // Hidden selected layers are discarded, as in Photoshop. + for id in &ids[..ids.len() - 1] { + doc.remove(*id); + } + *doc.layer_mut(top_id).ok_or(EngineError::NoLayer(top_id))? = merged; + *active = Some(mid); + Ok(mid) } - *doc.layer_mut(top_id).ok_or(EngineError::NoLayer(top_id))? = merged; - *active = Some(mid); - Ok(mid) })?; reselect(s, vec![mid], Some(mid)); Ok(json!({ "layer": mid.0 })) @@ -1386,15 +1597,15 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); + let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); let mut new_active = None; for id in top_level(doc, &sel) { // Match the single-layer duplicate path: Background copies become // ordinary layers, while normal copies retain their original locks. - let dup = crate::commands::layer_copy(doc, id)?; + let dup = crate::commands::layer_copy(doc, id, add_copy)?; let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -1408,7 +1619,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1514,6 +1724,23 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn merge_with_background_preserves_background() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); + let bg_id = s.active().unwrap().doc.layers[0].id; + assert!(s.active().unwrap().doc.is_background(bg_id)); + let l1 = LayerId(s.execute("layer.new.layer", json!({"name": "Generative Fill"})).unwrap()["layer"].as_u64().unwrap()); + set_selection(&mut s, vec![bg_id, l1], Some(l1), Some(bg_id)).unwrap(); + let res = s.execute("layer.mergeLayers", json!({})).unwrap(); + let mid = LayerId(res["layer"].as_u64().unwrap()); + assert_eq!(mid, bg_id); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert!(doc.is_background(mid)); + assert_eq!(doc.layers[0].name, "Background"); + } + #[test] fn duplicates_are_numbered_not_stacked() { let mut s = Session::new(); @@ -1780,6 +2007,118 @@ mod tests { assert_eq!(step_damage(&plain, &before), Some(Rect::EMPTY)); } + /// #6535: hiding or showing an adjustment clipped to a layer recomposites only that base's + /// area, also on undo and redo and inside a group, and recompositing just that area gives + /// the same canvas as a full recomposite. + #[test] + fn toggling_a_clipped_adjustment_damages_only_its_base() { + let all = |s: &Session| photocraft_compose::render(doc(s), doc(s).bounds()); + // Recomposite only the reported damage over the previous canvas, as the view does. + let patched = |s: &Session, prev: &photocraft_compose::Buffer| { + let r = damage(s).expect("bounded damage"); + let mut out = prev.clone(); + if r.is_empty() { + return out; + } + let part = photocraft_compose::render(doc(s), r); + let w = out.rect.width() as usize; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let i = (y - out.rect.y0) as usize * w + (x - out.rect.x0) as usize; + let k = (y - r.y0) as usize * r.width() as usize + (x - r.x0) as usize; + out.px[i] = part.px[k]; + } + } + out + }; + for grouped in [false, true] { + let mut s = session(16); + let base_rect = Rect::new(10, 10, 30, 25); + let base = rect_layer(&mut s, base_rect); + let adj = LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.createClippingMask", json!({"layer": adj.0})).unwrap(); + assert!(doc(&s).layer(adj).unwrap().clipped); + if grouped { + s.execute("layer.select", json!({"layer": base.0})).unwrap(); + s.execute("layer.select", json!({"layer": adj.0, "mode": "toggle"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + assert!(doc(&s).path_of(adj).unwrap().len() == 2, "the pair sits in a group"); + } + let shown = all(&s); + s.execute("layer.hideLayers", json!({"layer": adj.0})).unwrap(); + assert!(!doc(&s).layer(adj).unwrap().visible); + assert_eq!(damage(&s), Some(base_rect), "grouped: {grouped}"); + let hidden = all(&s); + assert_ne!(hidden, shown); + assert_eq!(patched(&s, &shown), hidden); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &hidden), shown); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &shown), hidden); + s.execute("layer.showLayers", json!({"layer": adj.0})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &hidden), shown); + // layer.setProps reports its own damage. + s.execute("layer.setProps", json!({"layer": adj.0, "visible": false})).unwrap(); + assert_eq!(damage(&s), Some(base_rect)); + assert_eq!(patched(&s, &shown), hidden); + // Unclipped, the adjustment still reaches the whole canvas. + s.execute("layer.setProps", json!({"layer": adj.0, "visible": true})).unwrap(); + s.execute("layer.releaseClippingMask", json!({"layer": adj.0})).unwrap(); + s.execute("layer.hideLayers", json!({"layer": adj.0})).unwrap(); + assert_eq!(damage(&s), None); + } + } + + /// #6535: hiding a clipped adjustment inside a pass-through group that is itself a clip base + /// can switch the group between isolated and pass-through rendering, so the step damages the + /// whole group, on undo, redo and when the adjustment is deleted too. + #[test] + fn clipped_adjustment_in_pass_through_clip_base_damages_its_group() { + use photocraft_doc::{Adjustment, BlendMode}; + let solid = |name: &str, r: Rect, blend: BlendMode| { + let mut l = Layer::raster(name, PixelFormat::RGBA8); + l.surface_mut().unwrap().fill_rect(r, &[0.5, 0.5, 0.5, 1.0]); + l.blend = blend; + l + }; + let mut before = Document::with_background( + "t", + photocraft_geom::Size::new(10, 8), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_color::Color::WHITE, + ); + before.layers.push(solid("bg", before.bounds(), BlendMode::Normal)); + let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert)); + a.clipped = true; + let mut g = Layer::group("G", vec![solid("P", Rect::new(2, 1, 4, 3), BlendMode::Normal), a, solid("Q", Rect::new(6, 4, 8, 6), BlendMode::Multiply)]); + g.blend = BlendMode::PassThrough; + before.layers.push(g); + let mut x = solid("X", Rect::new(2, 1, 3, 2), BlendMode::Normal); + x.clipped = true; + before.layers.push(x); + let mut after = before.clone(); + let LayerContent::Group(g) = &mut after.layers[2].content else { panic!("group") }; + g.children[1].visible = false; + let group = Some(Rect::new(2, 1, 8, 6)); + assert_eq!(step_damage(&before, &after), group); + assert_eq!(step_damage(&after, &before), group); + let mut deleted = before.clone(); + let LayerContent::Group(g) = &mut deleted.layers[2].content else { panic!("group") }; + g.children.remove(1); + assert_eq!(step_damage(&before, &deleted), group); + let LayerContent::Group(g) = &before.layers[2].content else { panic!("group") }; + let ids = [g.children[1].id]; + assert_eq!(layers_damage(&before, &after, &ids), group); + // Without the layer clipped to the group it stays pass-through: just A's base. + before.layers.truncate(3); + after.layers.truncate(3); + assert_eq!(step_damage(&before, &after), Some(Rect::new(2, 1, 4, 3))); + } + /// #2883 (P8/P9): a duplicate shares the source's tiles (no pixel copy) and recomposites only /// where it draws; undo brings back exactly the old pixels. A copy that becomes the clipping /// base of the layers above still recomposites everything. @@ -1823,8 +2162,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1847,6 +2186,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } @@ -2282,6 +2627,7 @@ mod tests { let docu = doc(&s); let gl = docu.layer(g).unwrap(); assert_eq!(gl.name, "Pair"); + assert!(matches!(&gl.content, LayerContent::Group(gr) if !gr.expanded), "created collapsed"); assert_eq!(gl.children().unwrap().iter().map(|l| l.id).collect::>(), vec![a, c]); // Root (bottom-to-top): Background, b, Pair (where c was), d. assert_eq!(docu.layers.iter().map(|l| l.id).skip(1).collect::>(), vec![b, g, d]); @@ -2290,8 +2636,10 @@ mod tests { assert_eq!(doc(&s).layers.len(), 5); // ⌘G with several selected groups them too. select_all(&mut s, &[b, d]); - s.execute("layer.groupLayers", json!({})).unwrap(); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); assert_eq!(doc(&s).layers.len(), 4); + // Both create the group collapsed, as Photoshop does (#5571). + assert!(matches!(&doc(&s).layer(g).unwrap().content, LayerContent::Group(gr) if !gr.expanded)); } #[test] @@ -2715,6 +3063,7 @@ mod tests { assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err()); } + /// `document.inspect` lists the selected layers and marks them in the layer list. #[test] fn inspect_reports_selection() { let mut s = session(8); @@ -2725,4 +3074,354 @@ mod tests { assert_eq!(v["selectedLayers"], json!([a.0, b.0])); assert_eq!(v["layers"][0]["selected"], true); } + + /// #7538: `selection: true` moves the selection with the layers in the same step, as the Move + /// tool does with several layers selected; without it the selection stays. + #[test] + fn translate_can_carry_the_selection_in_the_same_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap(); + let a = s.active().unwrap().active_layer.unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("select.rect", json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 70, "height": 30})).unwrap(); + let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()); + let bounds = |s: &Session, id: LayerId| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds(); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.translate", json!({"dx": 7, "dy": 20, "selection": true})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(17, 30, 37, 50), Rect::new(57, 30, 77, 50))); + assert_eq!(sel(&s), Some(Rect::new(12, 25, 82, 55)), "the selection came along"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step"); + s.undo(); + assert_eq!((sel(&s), bounds(&s, a)), (Some(Rect::new(5, 5, 75, 35)), Rect::new(10, 10, 30, 30)), "undone together"); + s.execute("layer.translate", json!({"dx": 7, "dy": 20})).unwrap(); + assert_eq!(sel(&s), Some(Rect::new(5, 5, 75, 35)), "without it the selection stays"); + let state = |s: &Session| (bounds(s, a), bounds(s, b), sel(s), s.active().unwrap().history.past_len()); + let before = state(&s); + for dx in [1, 0] { + assert!(s.execute("layer.translate", json!({"dx": dx, "selection": "yes"})).is_err(), "dx {dx}: a bad `selection` is an error"); + assert_eq!(state(&s), before, "dx {dx}: and changes nothing"); + } + } +} + +#[cfg(test)] +mod layers_panel_parity_tests { + use super::*; + + /// A 24×16 document with three layers, "Test 0" to "Test 2", above the Background; the last one is active. + fn new_session() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); + let ids = (0..3).map(|n| LayerId(s.execute("layer.new.layer", json!({"name": format!("Test {n}")})).unwrap()["layer"].as_u64().unwrap())).collect(); + (s, ids) + } + + /// Set one Layers panel option in the preferences, as the Panel Options dialog does. + fn option(s: &mut Session, key: &str, enabled: bool) { + s.prefs.edit(|p| { + let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({})); + if let Some(o) = val.as_object_mut() { + o.insert(key.to_owned(), json!(enabled)); + } + }); + } + + /// The drag preview equals the committed `layer.moveTo` result and never touches the document or its history. + #[test] + fn preview_matches_real_move_and_never_edits_document_or_history() { + let (mut s, ids) = new_session(); + let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "above"}); + let before = s.active().unwrap().doc.clone(); + let undo_before = s.active().unwrap().history.entries().len(); + let candidate = preview_move(&before, ¶ms, true).unwrap(); + assert_eq!(*before, *s.active().unwrap().doc); + assert_eq!(s.active().unwrap().history.entries().len(), undo_before); + s.execute("layer.moveTo", params).unwrap(); + assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order"); + } + + /// A previewed ⌥-drag copy names its layers like the committed copy, with and without the "copy" suffix option. + #[test] + fn preview_copy_and_no_copy_suffix_option_match_committed_layers() { + let (mut s, ids) = new_session(); + option(&mut s, "addCopyToCopiedLayersAndGroups", false); + let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "below", "copy": true}); + let before = s.active().unwrap().doc.clone(); + let candidate = preview_move(&before, ¶ms, false).unwrap(); + s.execute("layer.moveTo", params).unwrap(); + let actual = &s.active().unwrap().doc; + assert_eq!(candidate.layers.iter().map(|l| l.name.as_str()).collect::>(), actual.layers.iter().map(|l| l.name.as_str()).collect::>()); + assert_eq!(actual.layer_count(), before.layer_count() + 1); + assert_eq!(actual.layers.iter().filter(|l| l.name == "Test 0").count(), 2); + let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap(); + let duplicate = LayerId(d["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(duplicate).unwrap().name, "Test 1"); + option(&mut s, "addCopyToCopiedLayersAndGroups", true); + let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap(); + assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy")); + } + + /// Drops the command refuses (or ignores) give no preview and leave the document alone; a multi-layer preview follows the committed stack order. + #[test] + fn preview_rejects_invalid_drop_unchanged() { + let (mut s, ids) = new_session(); + let before = s.active().unwrap().doc.clone(); + // Dropping a layer on itself is a no-op for the command, the others are errors. + for (p, command_rejects) in [ + (json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), false), + (json!({"layer": ids[1].0, "target": 999999, "position": "below"}), true), + (json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), true), + (json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), true), + ] { + assert!(preview_move(&before, &p, true).is_none(), "{p}"); + let result = s.execute("layer.moveTo", p.clone()); + if command_rejects { + assert!(result.is_err(), "{p}"); + } else { + assert_eq!(result.unwrap(), Value::Null, "{p}"); + } + assert_eq!(*before, *s.active().unwrap().doc, "{p}"); + } + // Multi-layer preview and commit follow the same stack order regardless of params order. + let p = json!({"layers": [ids[2].0, ids[0].0], "target": ids[1].0, "position": "below"}); + let candidate = preview_move(&before, &p, true).unwrap(); + s.execute("layer.moveTo", p).unwrap(); + assert_eq!(candidate, *s.active().unwrap().doc); + } + + /// A 16×16 document holding `spec` (bottom to top, `*` = clipped) above its Background. Every + /// layer is a translucent, differently placed rectangle, so the stack order and the clip + /// groups both show in the composite. + fn clip_stack(spec: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + for (n, name) in spec.split_whitespace().enumerate() { + let id = LayerId(s.execute("layer.new.layer", json!({"name": name.trim_end_matches('*')})).unwrap()["layer"].as_u64().unwrap()); + let n = n as i32; + let colour = [(n % 3) as f32 * 0.5, ((n + 1) % 3) as f32 * 0.5, ((n + 2) % 3) as f32 * 0.5, 0.6]; + s.edit("paint", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.clipped = name.ends_with('*'); + layer.surface_mut().unwrap().fill_rect(Rect::new(n * 2, 0, n * 2 + 10, 16), &colour); + Ok(()) + }) + .unwrap(); + } + s + } + + /// Every layer of `doc` from the bottom, `*` marking the clipped ones. + fn stack_text(doc: &Document) -> String { + doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::>().join(" ") + } + + /// The id of the layer called `name`, searching the whole layer tree. + fn id_of(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0 + } + + /// The preview of `params` and the committed `layer.moveTo` show the same stack (names, clip + /// flags) and the same composite, and both are `want`. + fn assert_preview_is_commit(mut s: Session, params: Value, want: &str) { + let before = s.active().unwrap().doc.clone(); + let preview = preview_move(&before, ¶ms, true).expect("the drop has a preview"); + s.execute("layer.moveTo", params.clone()).unwrap(); + let committed = &s.active().unwrap().doc; + assert_eq!(stack_text(&preview), want, "preview of {params}"); + assert_eq!(stack_text(committed), want, "commit of {params}"); + assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}"); + } + + /// Previewed moves join and release clipping groups exactly as the committed reorder does, copies included. + #[test] + fn preview_follows_clip_groups_like_the_committed_reorder() { + // The fixtures of `clip_group::reorder_joins_and_leaves_groups`. + for (spec, layers, target, pos, want) in [ + // A layer dropped into a clip group joins it. + ("B C1* C2* U", vec!["U"], "C1", "above", "Background B C1* U* C2*"), + ("B C1* C2* U V", vec!["U", "V"], "C1", "above", "Background B C1* U* V* C2*"), + // A clipped layer dropped off its group is released. + ("B C1* C2* U", vec!["C1"], "U", "above", "Background B C2* U C1"), + // A base moved away releases the layers clipped to it. + ("B C1* C2* U", vec!["B"], "U", "above", "Background C1 C2 U B"), + // A base moved up into its own group joins it, and the layers it left are released. + ("B C1* C2*", vec!["B"], "C1", "above", "Background C1 B* C2"), + ("X B C1* C2* U", vec!["B", "C1"], "U", "above", "Background X C2 U B C1*"), + ] { + let s = clip_stack(spec); + let ids: Vec = layers.iter().map(|n| id_of(&s, n)).collect(); + let t = id_of(&s, target); + assert_preview_is_commit(s, json!({"layers": ids, "target": t, "position": pos}), want); + } + // An option-drag copy joins the clip group it lands in too. + let s = clip_stack("B C1* C2* U"); + let (u, c1) = (id_of(&s, "U"), id_of(&s, "C1")); + assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U"); + } + + /// Copying the locked Background in a preview gives the same unlocked copy, joining the clip group it lands in, as the command. + #[test] + fn preview_copies_the_background_like_the_committed_copy() { + let mut s = clip_stack("A B*"); + let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A")); + let before = s.active().unwrap().doc.clone(); + assert_ne!(before.layer(bg).unwrap().locks, Default::default(), "the Background is locked"); + let p = json!({"layer": bg.0, "target": a, "position": "above", "copy": true}); + let preview = preview_move(&before, &p, true).unwrap(); + s.execute("layer.moveTo", p).unwrap(); + let committed = &s.active().unwrap().doc; + // The copy is an ordinary, unlocked layer that joins the clip group it lands in. + assert_eq!(stack_text(committed), "Background A Background copy* B*"); + assert_eq!(stack_text(&preview), stack_text(committed)); + for doc in [&preview, committed] { + assert_eq!(doc.layers.get(2).unwrap().locks, Default::default()); + assert!(doc.layers.first().unwrap().locks != Default::default()); + } + assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px); + } + + /// A drop that would move the Background off the bottom has no preview, as the command refuses it. + #[test] + fn preview_refuses_what_would_leave_the_background_off_the_bottom() { + let mut s = clip_stack("A B"); + let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A")); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layer": a, "target": bg.0, "position": "below"}), + json!({"layer": bg.0, "target": bg.0, "position": "below", "copy": true}), + json!({"layer": a, "target": bg.0, "position": "below", "copy": true}), + ] { + assert!(preview_move(&before, &p, true).is_none(), "{p}"); + assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}"); + assert_eq!(*before, *s.active().unwrap().doc, "{p}"); + } + } + + /// The UI runs commands through `start`; Expand New Effects applies there as well as to `execute`. + #[test] + fn expand_new_effects_applies_to_ui_start_dispatch_too() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let target = ids[0]; + let started = s.start("layer.layerStyle.dropShadow", json!({"layer": target.0})).unwrap(); + assert!(matches!(started, crate::jobs::Started::Done(_))); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + } + + /// New Solid Color and Gradient fill layers get the default mask only while Use Default Masks on Fill Layers is on. + #[test] + fn fill_layers_respect_use_default_masks_option() { + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none()); + option(&mut s, "useDefaultMasksOnFillLayers", true); + let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap(); + let id = LayerId(new["layer"].as_u64().unwrap()); + assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some()); + } + + /// With the option off only the default reveal-all mask goes away: an active selection still + /// becomes the layer mask and an explicit path still becomes the vector mask (path first). + #[test] + fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() { + const KINDS: [(&str, &str); 3] = [ + ("layer.newFillLayer.solidColor", r##"{"color": "#112233"}"##), + ("layer.newFillLayer.gradient", "{}"), + ("layer.newFillLayer.pattern", r#"{"pattern": "Checkerboard"}"#), + ]; + let path = json!({"subpaths": [{"closed": true, "knots": [[2, 2], [10, 2], [10, 10], [2, 10]]}]}); + let (mut s, _) = new_session(); + option(&mut s, "useDefaultMasksOnFillLayers", false); + // Creates the fill layer; returns its raster mask and whether it has a vector mask. + let create = |s: &mut Session, cmd: &str, params: &str, path: Option<&Value>| { + let mut p: Value = serde_json::from_str(params).unwrap(); + if let Some(path) = path { + p["path"] = path.clone(); + } + let id = LayerId(s.execute(cmd, p).unwrap()["layer"].as_u64().unwrap()); + let l = s.active().unwrap().doc.layer(id).unwrap(); + (l.mask.clone(), l.vector_mask.is_some()) + }; + + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, None); + assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all"); + } + + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, None); + let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask")); + assert!(!vector, "{cmd}"); + assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals"); + assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides"); + } + + // An explicit path wins over the selection, as with the option on. + for selected in [true, false] { + if !selected { + s.execute("select.deselect", json!({})).unwrap(); + } + for (cmd, params) in KINDS { + let (mask, vector) = create(&mut s, cmd, params, Some(&path)); + assert!(vector, "{cmd}: the explicit path becomes the vector mask (selection: {selected})"); + assert!(mask.is_none(), "{cmd}: no raster mask next to the vector mask (selection: {selected})"); + } + } + } + + /// Whether new effects start expanded is view state: it adds no history step, and editing an existing effect keeps its disclosure. + #[test] + fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let target = ids[0]; + let history = s.active().unwrap().history.entries().len(); + s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 2})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), history + 1); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + option(&mut s, "expandNewEffects", true); + let other = ids[1]; + s.execute("layer.layerStyle.stroke", json!({"layer": other.0, "size": 2})).unwrap(); + assert!(!s.active().unwrap().fx_collapsed.contains(&other)); + // Editing the existing effect must not unexpectedly change its disclosure. + s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap(); + assert!(s.active().unwrap().fx_collapsed.contains(&target)); + } + + /// The disclosure follows the layer the command changes, however the command names it: a + /// layer given by name is not the active one. + #[test] + fn expand_new_effects_follows_a_layer_named_by_its_name() { + let (mut s, ids) = new_session(); + option(&mut s, "expandNewEffects", false); + let (named, active) = (ids[0], ids[2]); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + s.execute("layer.layerStyle.dropShadow", json!({"layer": "Test 0", "size": 2})).unwrap(); + assert!(s.active().unwrap().doc.layer(named).is_some_and(|l| !l.effects.items.is_empty())); + assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed"); + assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed"); + } + + /// Transfer Effects changes the layer it moves the effects to, so that is the layer whose + /// disclosure follows the option, not the active layer or the one the effects came from. + #[test] + fn expand_new_effects_follows_the_transfer_destination() { + let (mut s, ids) = new_session(); + let (from, to, active) = (ids[0], ids[1], ids[2]); + s.execute("layer.layerStyle.dropShadow", json!({"layer": from.0, "size": 2})).unwrap(); + option(&mut s, "expandNewEffects", false); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + s.execute("layer.layerStyle.transferEffects", json!({"from": from.0, "to": to.0, "copy": true})).unwrap(); + assert!(s.active().unwrap().doc.layer(to).is_some_and(|l| !l.effects.items.is_empty())); + assert!(s.active().unwrap().fx_collapsed.contains(&to), "the destination's new effects start collapsed"); + assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed"); + } } diff --git a/crates/engine/src/layer_nav_cmds.rs b/crates/engine/src/layer_nav_cmds.rs index bd1ecb661a..b41a18b590 100644 --- a/crates/engine/src/layer_nav_cmds.rs +++ b/crates/engine/src/layer_nav_cmds.rs @@ -118,6 +118,7 @@ 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(); let d = id(&mut s, "layer.new.layer", json!({"name": "D"})); (s, [bg, a, group, b, c, d]) } diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..68e9344e59 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -69,7 +69,7 @@ fn gradient(p: &Value) -> Gradient { stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), angle: f(p, "angle", 90.0), - scale: f(p, "scale", 100.0) / 100.0, + scale: (f(p, "scale", 100.0) / 100.0).clamp(0.1, 1.5), reverse: b(p, "reverse", false), ..Gradient::default() } @@ -86,19 +86,19 @@ pub fn effect_defaults(kind: &str) -> Value { json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) } "outerGlow" => { - json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) + json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) } "innerGlow" => { - json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0}) + json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "source": "edge", "choke": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0}) } - "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), + "stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), "gradientOverlay" => { json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { - json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) + json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "glossAntiAlias": false, "highlightBlend": "Screen", "highlightColor": "#ffffff", "highlightOpacity": 75, "shadowBlend": "Multiply", "shadowColor": "#000000", "shadowOpacity": 75, "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) } "satin" => { json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true}) @@ -460,16 +460,27 @@ pub fn contours_from_shc(bytes: &[u8]) -> Result> { /// An `.shc` file holding `contour` (Contour Editor › Save…). pub fn contour_to_shc(contour: &Contour) -> Result> { - let (name, points) = match contour { - Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]), - Contour::Custom { name, points, .. } => (name.clone(), points.clone()), - }; - let points = points + contour_to_shc_all(&[contour]) +} + +/// An `.shc` file holding every contour of `list` (Save Contours…). +pub fn contour_to_shc_all(list: &[&Contour]) -> Result> { + let file: Vec = list .iter() - .enumerate() - .map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) + .map(|contour| { + let (name, points) = match contour { + Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]), + Contour::Custom { name, points, .. } => (name.clone(), points.clone()), + }; + let points = points + .iter() + .enumerate() + .map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) + .collect(); + photocraft_psd::shc::ShcContour { name, points } + }) .collect(); - photocraft_psd::shc::write(&[photocraft_psd::shc::ShcContour { name, points }]).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) + photocraft_psd::shc::write(&file).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) } /// An effect of `kind` with its factory settings (before params are applied). @@ -507,9 +518,10 @@ fn fresh_effect(kind: &str) -> Option { "innerGlow" => Effect::InnerGlow(glow()), "stroke" => Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), - size: 3.0, - position: StrokePosition::Outside, - paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + size: 1.0, + position: StrokePosition::Inside, + paint: FxPaint::Color(Color::BLACK), + overprint: false, }), "colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }, "gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }, @@ -544,6 +556,7 @@ fn fresh_effect(kind: &str) -> Option { altitude: 30.0, use_global_light: true, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.75), @@ -582,7 +595,7 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { g.angle = x; } if let Some(x) = f_of(p, "scale") { - g.scale = (x / 100.0).clamp(0.01, 10.0); + g.scale = (x / 100.0).clamp(0.1, 1.5); } if let Some(x) = b_of(p, "reverse") { g.reverse = x; @@ -594,6 +607,9 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { /// a dialog round trip never touches what it doesn't model: an imported /// gradient stroke keeps its gradient while its opacity is edited. 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; @@ -690,6 +706,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { _ => 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") { @@ -789,6 +806,22 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { 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") { @@ -821,6 +854,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { /// Builds an effect of `kind` from JSON params (Photoshop units: opacity, /// spread, range in percent; sizes in px; angles in degrees). pub fn effect_from_params(kind: &str, p: &Value) -> Option { + crate::commands::check_f32_values("layer.layerStyle", p).ok()?; let mut fx = fresh_effect(kind)?; overlay_effect(&mut fx, p); Some(fx) @@ -856,6 +890,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; + crate::commands::check_f32_values(CMD, p)?; let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { @@ -894,6 +929,9 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { crate::pattern_cmds::ensure_in_doc(doc, pat); } let mode = doc.mode; + if !list.is_empty() { + crate::extra_cmds::background_to_layer(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; // The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye) // survives the edit: the dialog models per-effect state, not the switch (#3648). A layer @@ -905,6 +943,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { } fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { + crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?; let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); @@ -916,6 +955,7 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { } // Colours in the document's mode, as Image › Mode keeps them. let fx = fx.in_mode(doc.mode); + crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; // Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh // effects list already has the default `enabled: true`, so a first effect still shows. @@ -936,6 +976,7 @@ fn make_default(s: &mut Session, p: &Value) -> Result { Some(v) if v.is_object() => v.clone(), _ => return Err(bad(CMD, "`params` must be an object")), }; + crate::commands::check_f32_values(CMD, ¶ms)?; s.presets.layer_defaults.insert(kind.to_string(), params); s.presets_changed(); Ok(json!({"kind": kind})) @@ -995,17 +1036,17 @@ pub fn specs() -> Vec { style_cmd!( "outerGlow", "Outer Glow…", - r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## ), style_cmd!( "innerGlow", "Inner Glow…", - r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## + r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"## ), style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( @@ -1021,7 +1062,7 @@ pub fn specs() -> Vec { style_cmd!( "bevelEmboss", "Bevel & Emboss…", - r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"## + r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"glossAntiAlias":bool,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"## ), style_cmd!( "satin", @@ -1137,6 +1178,81 @@ mod tests { assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true)); } + /// #6698 review: a glow's Range runs 1..100 %, as its slider and Photoshop's do. A 0 is + /// held at the 1 % floor, and 100 is kept. + #[test] + fn glow_range_is_held_to_one_through_one_hundred() { + for kind in ["outerGlow", "innerGlow"] { + for (sent, kept) in [(0.0, 0.01), (-5.0, 0.01), (1.0, 0.01), (50.0, 0.5), (100.0, 1.0), (250.0, 1.0)] { + let mut s = session(); + s.execute(&format!("layer.layerStyle.{kind}"), json!({"range": sent})).unwrap(); + let range = match &effects(&s)[0] { + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.range, + other => panic!("{other:?}"), + }; + assert!((range - kept).abs() < 1e-6, "{kind} range {sent} kept as {range}, not {kept}"); + } + } + let doc = |id: &str| specs().into_iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("layer.layerStyle.outerGlow").contains(r#""range":1..100=50"#)); + assert!(doc("layer.layerStyle.innerGlow").contains(r#""range":1..100=50"#)); + } + + #[test] + fn bevel_gloss_anti_alias_is_a_param() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap(); + let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() }; + assert!(b.gloss_anti_alias); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossAntiAlias": false})).unwrap(); + let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() }; + assert!(!b.gloss_anti_alias); + } + + #[test] + fn stroke_overprint_is_a_param() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap(); + let Effect::Stroke(st) = &effects(&s)[0] else { panic!() }; + assert!(st.overprint); + s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap(); + let Effect::Stroke(st) = &effects(&s)[0] else { panic!() }; + assert!(!st.overprint); + } + + #[test] + fn bevel_shading_fields_are_independent_and_preserved_on_partial_edits() { + let mut s = session(); + s.execute( + "layer.layerStyle.bevelEmboss", + json!({ + "highlightBlend": "Normal", "highlightColor": "#ff0000", "highlightOpacity": 35, + "shadowBlend": "Color Burn", "shadowColor": "#0000ff", "shadowOpacity": 60 + }), + ) + .unwrap(); + let fx = effects(&s); + let Effect::BevelEmboss(bevel) = &fx[0] else { panic!("{fx:?}") }; + assert_eq!(bevel.highlight.blend, BlendMode::Normal); + assert_eq!(bevel.highlight_color, Color::rgb(1.0, 0.0, 0.0)); + assert!((bevel.highlight.opacity - 0.35).abs() < 0.001); + assert_eq!(bevel.shadow.blend, BlendMode::ColorBurn); + assert_eq!(bevel.shadow_color, Color::rgb(0.0, 0.0, 1.0)); + assert!((bevel.shadow.opacity - 0.60).abs() < 0.001); + + let mut changed = fx[0].clone(); + overlay_effect(&mut changed, &json!({"size": 12})); + let Effect::BevelEmboss(partial) = &changed else { panic!("expected bevel") }; + assert_eq!(partial.highlight, bevel.highlight); + assert_eq!(partial.highlight_color, bevel.highlight_color); + assert_eq!(partial.shadow, bevel.shadow); + assert_eq!(partial.shadow_color, bevel.shadow_color); + + overlay_effect(&mut changed, &json!({"highlightOpacity": 120, "shadowOpacity": -30})); + let Effect::BevelEmboss(limits) = &changed else { panic!("expected bevel") }; + assert_eq!((limits.highlight.opacity, limits.shadow.opacity), (1.0, 0.0)); + } + #[test] fn every_style_command_adds_its_effect() { for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { @@ -1152,17 +1268,25 @@ mod tests { /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width /// is capped at the effect reach, so the document still renders, quickly. #[test] - fn oversized_satin_and_bevel_sizes_render() { - for (cmd, p) in [ - ("satin", json!({"size": 1e308})), - ("satin", json!({"size": 1_000_000})), - ("bevelEmboss", json!({"size": 1e308})), - ("bevelEmboss", json!({"soften": 1e308})), - ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})), + fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() { + for (cmd, p, accepted) in [ + ("satin", json!({"size": 1e308}), false), + ("satin", json!({"size": 1e30}), true), + ("satin", json!({"size": 1_000_000}), true), + ("bevelEmboss", json!({"size": 1e308}), false), + ("bevelEmboss", json!({"soften": 1e308}), false), + ("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true), + ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true), ] { let mut s = session(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()); + if accepted { + result.unwrap(); + } else { + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + } + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); let start = std::time::Instant::now(); s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed()); @@ -1190,6 +1314,34 @@ mod tests { assert!(matches!(&effects(&s)[2], Effect::DropShadow(sh) if sh.common.blend == BlendMode::Normal && sh.distance == 12.0)); } + /// The gradient overlay and the gradient stroke keep their scale in Photoshop's 10-150 %. + #[test] + fn gradient_overlay_and_stroke_scale_stay_in_range() { + let scale = |s: &Session| match &effects(s)[0] { + Effect::GradientOverlay { gradient, .. } => gradient.scale, + Effect::Stroke(st) => match &st.paint { + FxPaint::Gradient(g) => g.scale, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + for (cmd, p, want) in [ + ("gradientOverlay", json!({"scale": 1000}), 1.5), + ("gradientOverlay", json!({"scale": 1}), 0.1), + ("gradientOverlay", json!({"scale": 80}), 0.8), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": -50}), 0.1), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 0}), 0.1), + ("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 400}), 1.5), + ] { + let mut s = session(); + s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + assert!((scale(&s) - want).abs() < 1e-6, "{cmd} {p}: {}", scale(&s)); + } + // Beyond f32 range is refused before any edit (commands.rs), not clamped. + let err = session().execute("layer.layerStyle.gradientOverlay", json!({"scale": 1e39})).unwrap_err(); + assert!(format!("{err:?}").contains("finite 32-bit"), "{err:?}"); + } + #[test] fn clear_and_undo() { let mut s = session(); @@ -1313,6 +1465,7 @@ mod tests { size: 7.0, position: StrokePosition::Outside, paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }), + overprint: false, }); let r = s .execute( @@ -1349,6 +1502,7 @@ mod tests { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::BLACK), + overprint: false, }); for p in [ json!({}), @@ -1503,7 +1657,7 @@ mod tests { // Factory default first: Reset without a saved default gives the factory set. let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap(); assert_eq!(r["user"], json!(false)); - assert_eq!(r["params"]["size"], json!(3)); + assert_eq!(r["params"]["size"], json!(1)); s.execute( "layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": {"size": 9, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}}), @@ -1518,6 +1672,22 @@ mod tests { assert!(s.execute("layer.layerStyle.defaultFor", json!({"kind": "nope"})).is_err()); } + /// #7765: a stroke built without params was red, while the dialog's factory default was + /// black. Both now give Photoshop's Reset to Default: 1 px, inside, Normal, 100 %, black. + #[test] + fn fresh_effects_match_the_dialog_factory_defaults() { + for kind in + ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "satin", "bevelEmboss"] + { + let bare = effect_from_params(kind, &json!({})).unwrap(); + let dialog = effect_from_params(kind, &effect_defaults(kind)).unwrap(); + assert_eq!(bare, dialog, "{kind}"); + } + let Some(Effect::Stroke(st)) = effect_from_params("stroke", &json!({})) else { panic!("no stroke") }; + assert_eq!((st.size, st.position, st.common.blend, st.common.opacity), (1.0, StrokePosition::Inside, BlendMode::Normal, 1.0)); + assert_eq!(st.paint, FxPaint::Color(Color::BLACK)); + } + #[test] fn layer_style_defaults_persist_with_presets() { let mut s = session(); diff --git a/crates/engine/src/layer_xmp_cmds.rs b/crates/engine/src/layer_xmp_cmds.rs new file mode 100644 index 0000000000..4fb4eda32f --- /dev/null +++ b/crates/engine/src/layer_xmp_cmds.rs @@ -0,0 +1,96 @@ +//! A layer's own XMP packet, as Photoshop scripts reach it through `layer.xmpMetadata.rawData`: +//! scripts and embedding apps keep data about a layer there under their own namespaces. It is +//! saved in PSD, PSB, layered TIFF and `.pcraft`, and copied when the layer is duplicated. + +use std::sync::Arc; + +use photocraft_doc::LayerId; +use serde_json::{Map, Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const ID: &str = "layer.xmpMetadata"; + +/// Largest packet accepted, in UTF-8 bytes. Every history state shares the packet, but a PSD +/// stores it as UTF-16 in each layer's record. +pub const MAX_LAYER_XMP: usize = 4 << 20; + +fn bad(message: impl Into) -> EngineError { + EngineError::BadParams { cmd: ID.into(), msg: message.into() } +} + +fn enabled(s: &Session) -> std::result::Result<(), String> { + crate::active_layer_of(s).map(|_| ()) +} + +/// `raw` if it is an XMP packet: well-formed XML (no DTD) whose root is `x:xmpmeta` or +/// `rdf:RDF`, without NUL characters (Photoshop stores the packet NUL-terminated). +fn check_packet(raw: &str) -> Result<()> { + if raw.len() > MAX_LAYER_XMP { + return Err(bad(format!("`rawData` is {} bytes; the most a layer holds is {MAX_LAYER_XMP}", raw.len()))); + } + 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 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 { + return Err(bad("`rawData` must be an XMP packet: its root element is x:xmpmeta or rdf:RDF")); + } + Ok(()) +} + +fn xmp_metadata(s: &mut Session, p: &Value) -> Result { + let empty = Map::new(); + let p = match p { + Value::Null => &empty, + Value::Object(m) => m, + _ => return Err(bad("expected an object")), + }; + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = match p.get("layer") { + Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be an unsigned integer"))?), + None => st.active_layer.ok_or_else(|| bad("no active layer"))?, + }; + let current = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.xmp.clone(); + let new = match p.get("rawData") { + None => return Ok(json!({"layer": id.0, "rawData": current.as_deref(), "changed": false})), + Some(Value::Null) => None, + Some(Value::String(raw)) => { + check_packet(raw)?; + Some(raw.as_str()) + } + Some(_) => return Err(bad("`rawData` must be a string (an XMP packet) or null (remove it)")), + }; + let changed = current.as_deref() != new; + if changed { + let stored: Option> = new.map(Into::into); + s.edit("Layer Metadata", move |doc, _| { + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.xmp = stored; + Ok(()) + })?; + if let Some(st) = s.active_mut() { + // Saved metadata only; no canvas pixels need recompositing. + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + } + } + Ok(json!({"layer": id.0, "rawData": new, "changed": changed})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "Layer XMP Metadata", + menu: &[], + shortcut: None, + params: r#"{"layer":id?,"rawData":string|null?} → {"layer","rawData","changed"} (no layer: the active layer; no rawData: read it; a string sets the layer's XMP packet, null removes it; as Photoshop's layer.xmpMetadata.rawData)"#, + enabled, + run: xmp_metadata, + journal: true, + }] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/layer_xmp_cmds/tests.rs b/crates/engine/src/layer_xmp_cmds/tests.rs new file mode 100644 index 0000000000..015f739399 --- /dev/null +++ b/crates/engine/src/layer_xmp_cmds/tests.rs @@ -0,0 +1,106 @@ +use super::*; +use photocraft_doc::Layer; + +const PACKET: &str = r#"120"#; + +fn fixture(depth: u8) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "depth": depth})).unwrap(); + let (a, b) = s + .edit("Fixture", |doc, _| { + let a = Layer::raster("A", doc.pixel_format()); + let b = Layer::group("B", vec![Layer::raster("Child", doc.pixel_format())]); + let ids = (a.id, b.id); + doc.layers = vec![a, b]; + Ok(ids) + }) + .unwrap(); + s.select_layer(a).unwrap(); + (s, a, b) +} + +fn xmp(s: &Session, id: LayerId) -> Option { + s.active().unwrap().doc.layer(id).unwrap().xmp.as_deref().map(str::to_string) +} + +#[test] +fn sets_reads_and_removes_as_one_undo_step_each() { + for depth in [8, 16, 32] { + let (mut s, a, b) = fixture(depth); + assert_eq!(s.execute(ID, json!({})).unwrap(), json!({"layer": a.0, "rawData": null, "changed": false})); + let past = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.clone(); + + // The active layer by default; a group by id. + assert_eq!(s.execute(ID, json!({"rawData": PACKET})).unwrap(), json!({"layer": a.0, "rawData": PACKET, "changed": true})); + assert_eq!(s.execute(ID, json!({"layer": b.0, "rawData": PACKET})).unwrap()["changed"], true); + assert_eq!(s.active().unwrap().history.past_len(), past + 2); + assert_eq!(s.active().unwrap().last_damage, Some(photocraft_geom::Rect::EMPTY)); + assert_eq!(s.execute(ID, Value::Null).unwrap()["rawData"], PACKET); + assert_eq!(crate::inspect::layer(s.active().unwrap().doc.layer(b).unwrap())["hasXmp"], true); + + // Only the packets changed. + let mut expected = (*before).clone(); + for id in [a, b] { + expected.layer_mut(id).unwrap().xmp = Some(PACKET.into()); + } + assert_eq!(*s.active().unwrap().doc, expected, "depth {depth}"); + + // The same packet again is not an edit. + assert_eq!(s.execute(ID, json!({"rawData": PACKET})).unwrap()["changed"], false); + assert_eq!(s.active().unwrap().history.past_len(), past + 2); + + assert_eq!(s.execute(ID, json!({"rawData": null})).unwrap(), json!({"layer": a.0, "rawData": null, "changed": true})); + assert_eq!(xmp(&s, a), None); + assert!(s.undo()); + assert_eq!(xmp(&s, a).as_deref(), Some(PACKET)); + assert!(s.undo() && s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(xmp(&s, a).as_deref(), Some(PACKET)); + } +} + +#[test] +fn duplicating_a_layer_copies_its_packet() { + let (mut s, a, _) = fixture(8); + s.execute(ID, json!({"rawData": PACKET})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + let copy = s.active().unwrap().active_layer.unwrap(); + assert_ne!(copy, a); + assert_eq!(xmp(&s, copy).as_deref(), Some(PACKET)); +} + +#[test] +fn refuses_what_is_not_an_xmp_packet() { + let (mut s, a, _) = fixture(8); + let refused = |s: &mut Session, p: Value| matches!(s.execute(ID, p), Err(EngineError::BadParams { .. })); + for p in [ + json!([]), + json!({"layer": "A"}), + json!({"rawData": 7}), + json!({"rawData": ""}), + json!({"rawData": ""}), + json!({"rawData": ""}), + json!({"rawData": "\u{0}"}), + json!({"rawData": "]>&a;"}), + json!({"rawData": format!("{}", "a".repeat(MAX_LAYER_XMP))}), + ] { + assert!(refused(&mut s, p.clone()), "{p:.80}"); + } + let r = s.execute(ID, json!({"layer": 999_999_999})); + assert!(matches!(r, Err(EngineError::UnknownLayer { .. })), "{r:?}"); + assert_eq!(xmp(&s, a), None); + // A bare RDF root and a packet wrapper are packets. + let rdf = r#""#; + assert_eq!(s.execute(ID, json!({"rawData": rdf})).unwrap()["changed"], true); + let wrapped = format!("{PACKET}"); + assert_eq!(s.execute(ID, json!({"rawData": wrapped})).unwrap()["changed"], true); +} + +#[test] +fn needs_a_document_and_a_layer() { + let mut s = Session::new(); + assert!(matches!(s.execute(ID, json!({})), Err(EngineError::Disabled(..)))); + assert!(enabled(&s).is_err()); +} diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..c68469f40f 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -62,16 +62,10 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "edgeExtension" | "extension" => EdgeMode::Extension, "black" => EdgeMode::Color([0.0, 0.0, 0.0, 1.0]), "white" => EdgeMode::Color([1.0, 1.0, 1.0, 1.0]), - "background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])), + "background" | "color" => EdgeMode::Color(crate::commands::color_param(cmd, p, "edgeColor", [0.0, 0.0, 0.0, 1.0])?), other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))), }; - let focal = num(p, "focalLength", 0.0); - lc.profile = match p.get("profile").and_then(Value::as_str).unwrap_or("none") { - "none" | "" => None, - "generic" => Some(lens::generic_profile(if focal > 0.0 { focal } else { 50.0 })), - "auto" => info.and_then(|i| i.focal_length_35mm.or(i.focal_length)).or((focal > 0.0).then_some(focal)).map(lens::generic_profile), - other => return Err(bad(cmd, format!("unknown profile `{other}` (none|auto|generic)"))), - }; + lc.profile = profile_focal(cmd, p, info)?.map(lens::generic_profile); if let Some(Value::Array(line)) = p.get("straighten") { let pt = |v: &Value| -> Option<[f64; 2]> { Some([v.get(0)?.as_f64()?, v.get(1)?.as_f64()?]) }; let (Some(a), Some(b)) = (line.first().and_then(pt), line.get(1).and_then(pt)) else { @@ -85,8 +79,20 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera Ok(lc) } -/// Writes the resolved profile / angle / scale back into the params (for smart filters). -fn resolved_lens_params(p: &Value, lc: &LensCorrection) -> Value { +/// The 35 mm-equivalent focal length of the generic profile the params ask for (`None`: no profile). +fn profile_focal(cmd: &str, p: &Value, info: Option<&exif::CameraInfo>) -> Result> { + let focal = num(p, "focalLength", 0.0); + Ok(match p.get("profile").and_then(Value::as_str).unwrap_or("none") { + "none" | "" => None, + "generic" => Some(if focal > 0.0 { focal } else { 50.0 }), + "auto" => info.and_then(|i| i.focal_length_35mm.or(i.focal_length)).or((focal > 0.0).then_some(focal)), + other => return Err(bad(cmd, format!("unknown profile `{other}` (none|auto|generic)"))), + }) +} + +/// Writes the resolved profile (its exact focal length, not the rounded one in its name) / +/// angle / scale back into the params (for smart filters). +fn resolved_lens_params(p: &Value, lc: &LensCorrection, focal: Option) -> Value { let mut q = p.clone(); if let Value::Object(m) = &mut q { m.remove("straighten"); @@ -94,10 +100,9 @@ fn resolved_lens_params(p: &Value, lc: &LensCorrection) -> Value { m.remove("layer"); m.insert("angle".into(), json!(lc.angle)); m.insert("scale".into(), json!(lc.scale)); - match &lc.profile { - Some(pr) => { + match lc.profile.as_ref().and(focal) { + Some(f) => { m.insert("profile".into(), json!("generic")); - let f = pr.name.trim_start_matches("Generic Lens (").split(' ').next().and_then(|v| v.parse::().ok()).unwrap_or(50.0); m.insert("focalLength".into(), json!(f)); } None => { @@ -147,6 +152,9 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result { cr.noise_luminance_detail = cr.noise_luminance_detail.clamp(0.0, 100.0); cr.noise_color = cr.noise_color.clamp(0.0, 100.0); cr.noise_color_detail = cr.noise_color_detail.clamp(0.0, 100.0); + // `exposure` becomes `2^exposure`, which overflows f32 to infinity from 128 up (and then + // writes inf/NaN into 32-bit layers). Clamp to the documented -5..5. + cr.exposure = cr.exposure.clamp(-5.0, 5.0); Ok(cr) } @@ -289,13 +297,14 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: let locks = doc.effective_locks(id); let background = doc.is_background(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // A pixel or full lock refuses the smart filter too, as it does for other filters. + if locks.all || locks.pixels { + return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + } if let LayerContent::Smart(_) = l.content { let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } - if locks.all || locks.pixels { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); - } // A Background layer gains transparency when the edge mode exposes it (as in Photoshop). if float_bg && background { l.locks.position = false; @@ -320,7 +329,7 @@ fn lens_correction(s: &mut Session, p: &Value) -> Result { let frame = st.doc.bounds(); let info = camera_info(&st.doc); let lc = lens_params(LENS, p, frame, info.as_ref())?; - let stored = resolved_lens_params(p, &lc); + let stored = resolved_lens_params(p, &lc, profile_focal(LENS, p, info.as_ref())?); let t0 = Stopwatch::start(); let id = run_filter(s, LENS, "Lens Correction", stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?; @@ -424,7 +433,7 @@ fn lens_batch(s: &mut Session, p: &Value) -> Result { // Batch default: the EXIF-driven profile, as Photoshop's dialog suggests. m.entry("profile").or_insert(json!("auto")); } - let r = crate::file_cmds::process_files(&inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(s, p, LENS_BATCH)?, "", &|scratch| { + let r = crate::file_cmds::process_files(s, &inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(s, p, LENS_BATCH)?, "", &|scratch| { if scratch.active().is_some_and(|d| d.doc.layers.len() > 1) { scratch.execute("layer.flattenImage", json!({}))?; } @@ -554,6 +563,63 @@ mod tests { assert!(corner[0] > 0.4, "{corner:?}"); } + #[test] + fn lens_smart_filter_keeps_the_exact_focal_length() { + // A generic profile at a fractional focal length, and the auto profile from EXIF. + let generic = json!({"profile": "generic", "focalLength": 27.4, "edge": "edgeExtension"}); + let auto = json!({"profile": "auto", "edge": "edgeExtension"}); + for (p, focal) in [(generic, 27.4), (auto, 18.5)] { + let mut s = session(8); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif::build(&exif::CameraInfo { focal_length: Some(18.5), ..Default::default() }))); + Ok(()) + }) + .unwrap(); + let d = s.active().unwrap(); + let src = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().clone(); + let (frame, info) = (d.doc.bounds(), camera_info(&d.doc)); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let r = s.execute(LENS, p.clone()).unwrap(); + assert_eq!(r["params"]["focalLength"], focal, "{r}"); + // Re-rendering from the stored params gives the pixels the command rendered. + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().content else { panic!("smart") }; + let original = lens::correct(&src, frame, &lens_params(LENS, &p, frame, info.as_ref()).unwrap()); + let rerender = apply_to_surface(LENS, &sm.smart_filters[0].params, &src, frame).unwrap(); + assert!(original == rerender, "re-render differs for {p}"); + } + } + + #[test] + fn locked_smart_objects_refuse_lens_filters() { + // As other filters do: a pixel or full lock (the layer's own or its group's) refuses the + // smart filter, leaving the layer and the history as they were. + let filters = [(LENS, json!({"distortion": 10})), (WIDE, json!({"model": "perspective", "scale": 90})), (RAW, json!({"saturation": -100}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + for (cmd, p) in &filters { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the smart object ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} edited a smart object locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(layer).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{cmd} {lock} group: {on_group}: no history step"); + } + } + } + } + #[test] fn adaptive_wide_angle_straightens() { let mut s = session(8); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22b1e6d728..64814126cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,4 +1,4 @@ -//! The Photocraft engine façade: open documents, history, and the command registry. +//! The PhotoCraft engine façade: open documents, history, and the command registry. //! //! Every user-visible action is a command with a stable id (Photoshop-style, such as //! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same @@ -30,10 +30,12 @@ pub mod color_cmds; pub mod color_to_alpha_cmds; pub mod commands; pub mod comps_cmds; +pub mod contour_lib_cmds; mod crop_fill; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; +pub mod doc_path; pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; @@ -57,7 +59,9 @@ pub mod group_view_cmds; pub mod guide_layout; pub mod hidden_target; pub mod history_cmds; +pub mod history_menu_cmds; pub mod history_select_cmds; +pub mod history_source_cmds; pub mod image_cmds; pub mod inspect; pub mod installed_profiles; @@ -71,6 +75,7 @@ pub mod layer_menu_cmds; pub mod layer_multi_cmds; pub mod layer_nav_cmds; pub mod layer_style; +pub mod layer_xmp_cmds; pub mod lens_cmds; pub mod link_watch; pub mod magnetic_cmds; @@ -208,6 +213,8 @@ pub struct DocState { /// Increments on every change; UIs re-render when it moves. pub revision: u64, pub saved_revision: u64, + /// Native save cache shared with this document's workers, released with the editing state. + pub pcraft_cache: photocraft_io::PcraftExportCache, /// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those. pub view_revision: u64, /// Area changed by the latest revision (None = assume everything changed). @@ -238,6 +245,9 @@ pub struct DocState { pub snapshots: Vec, /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. next_snapshot: u64, + /// The snapshot set as the History Brush's source (`history.setSource {snapshot}`); `None`: + /// the source is a state, kept by the history ([`photocraft_ops::History::brush_source`]). + pub(crate) source_snapshot: Option, } impl DocState { @@ -269,6 +279,7 @@ impl DocState { source_read_only: false, revision: 1, saved_revision: 1, + pcraft_cache: Default::default(), view_revision: 0, last_damage: None, coalesce: None, @@ -281,6 +292,7 @@ impl DocState { color_samplers: Vec::new(), snapshots: Vec::new(), next_snapshot: 1, + source_snapshot: None, } } /// The selected layers in bottom-to-top document order, always including the active layer. @@ -297,6 +309,21 @@ impl DocState { pub fn is_dirty(&self) -> bool { self.revision != self.saved_revision } + /// Whether the step from `before` to the current document leaves a clean document clean: + /// it changed nothing but the selection (#6865). Making, changing or dropping a selection + /// (marquee, lasso, Magic Wand, Quick / Object Selection, Select › …) is a history step but + /// not an edit, so it neither marks the file modified nor makes closing ask to save. + pub(crate) fn keeps_clean(&self, before: &Document) -> bool { + self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc) + } + /// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved + /// revision with it. + pub(crate) fn bump_revision(&mut self, keep_clean: bool) { + self.revision += 1; + if keep_clean { + self.saved_revision = self.revision; + } + } /// The targeted layers, as history stores them with each state. fn layer_target(&self) -> LayerTarget { LayerTarget { active: self.active_layer, selected: self.selected_layers.clone() } @@ -485,6 +512,9 @@ pub struct Session { /// the same check a top-level request sees. `None` runs everything, which is what a local /// UI and `photocraft-cli run` do. pub authorize: Option Result<()>>, + /// 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, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, @@ -493,6 +523,9 @@ pub struct Session { /// The canvas-geometry map the next [`Session::edit`] applies to the Color Sampler points (crop, /// Canvas Size, Image Size, rotation set it just before their edit). Consumed by the edit. sampler_map: Option, + /// The same edit's map in edge coordinates, for the painting symmetry (Image Rotation maps the + /// samplers' pixel centres instead). Consumed by the edit. + symmetry_map: Option, /// Each open document's linked smart objects' files: size and modification time when last /// seen ([`link_watch`]). pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, @@ -561,8 +594,16 @@ impl Session { /// Move the Color Sampler points through `a` in the next [`Session::edit`] (crop, Canvas Size, /// Image Size, rotation), dropping the ones that land outside the new canvas. View state: the /// points stay pinned to their pixels, no history step. Cleared by the edit it is set for. + /// The painting symmetry moves through `a` too. pub(crate) fn set_sampler_map(&mut self, a: photocraft_geom::Affine) { - self.sampler_map = Some(a); + self.set_canvas_map(a, a); + } + + /// [`set_sampler_map`](Self::set_sampler_map) for an edit whose sampler map differs from its + /// geometry map (a flip or rotation maps pixel centres): `geometry` moves the painting symmetry. + pub(crate) fn set_canvas_map(&mut self, geometry: photocraft_geom::Affine, samplers: photocraft_geom::Affine) { + self.sampler_map = Some(samplers); + self.symmetry_map = Some(geometry); } pub fn sync_tools_mask_target(&mut self) { @@ -787,8 +828,10 @@ impl Session { // 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); + let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); let restrict = self.color_restrict; let sampler_map = self.sampler_map.take(); + let symmetry_map = self.symmetry_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); // Selecting layers is not a step, so the state this edit leaves behind targets what was @@ -824,12 +867,17 @@ impl Session { st.color_samplers.retain(|p| bounds.contains(p[0].floor() as i32, p[1].floor() as i32)); samplers_removed = n - st.color_samplers.len(); } + // The painting symmetry stays on its pixels too (Photoshop keeps it as a path). + if let Some(a) = symmetry_map { + st.symmetry = st.symmetry.take().and_then(|sym| sym.transform(&a)); + } // The pixels the edit can have changed, as its undo would report them: nothing for a // selection, guides or a layer's name (a new lasso selection used to recomposite a large // document, seconds on the CPU path, #1773), a new or deleted layer's bounds (a // duplicate or a paste), and everything when that can't be bounded. A command may still // report its own, tighter damage after this. let damage = layer_multi_cmds::step_damage(&before, &st.doc); + let keep_clean = st.keeps_clean(&before); let key = self.coalesce_request.clone(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let layers = st.layer_target(); @@ -841,7 +889,7 @@ impl Session { st.history.set_current_layers(layers); } st.coalesce = key; - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; self.samplers_removed += samplers_removed; if damage.is_none_or(|d| !d.is_empty()) { @@ -872,6 +920,31 @@ impl Session { Ok(()) } + /// Put back a layer selection (active layer, selected layers, anchor) that a step moved, such + /// as the undo ending an empty Type Mask, which restores the selection from before it. `None` + /// is no layer selected. Selected layers that are gone are dropped; an active layer that is + /// gone is an error and changes nothing. Like [`Self::select_layer`], not an edit. + pub fn restore_layer_selection(&mut self, active: Option, selected: &[LayerId], anchor: Option) -> Result<()> { + let st = self.active_mut().ok_or(EngineError::NoDocument)?; + if let Some(id) = active + && st.doc.layer(id).is_none() + { + return Err(EngineError::NoLayer(id)); + } + st.active_layer = active; + st.selected_layers = selected.to_vec(); + st.layer_anchor = anchor.filter(|_| active.is_some()); + fix_selection(st); + channel_cmds::fix_view(st); + let clean = st.saved_revision == st.revision; + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + Ok(()) + } + /// Re-renders the pixels of type layers whose font arrived after they were drawn (the web /// build fetches served fonts on demand, `photocraft_text::served`). The layers' model is /// unchanged, so this is no history step and leaves a clean document clean. Unknown documents @@ -922,6 +995,18 @@ impl Session { let Some(st) = self.active_mut() else { return false }; st.coalesce = None; 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). + Some((d, layers)) if Arc::ptr_eq(&d, &st.doc) => { + restore_target(st, layers); + let clean = !st.is_dirty(); + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + true + } Some((mut d, layers)) => { // Save As changes file identity outside history, just like the saved path. if d.name != st.doc.name { @@ -930,9 +1015,10 @@ impl Session { // 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); - st.doc = d; + let before = std::mem::replace(&mut st.doc, d); + let keep_clean = st.keeps_clean(&before); restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; true } @@ -952,9 +1038,10 @@ impl Session { Arc::make_mut(&mut d).name.clone_from(&st.doc.name); } let damage = layer_multi_cmds::step_damage(&st.doc, &d); - st.doc = d; + let before = std::mem::replace(&mut st.doc, d); + let keep_clean = st.keeps_clean(&before); restore_target(st, layers); - st.revision += 1; + st.bump_revision(keep_clean); st.last_damage = damage; true } diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index dbbfec2be7..453af7806b 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -35,15 +35,17 @@ pub type Stamp = (u64, u128); /// ids, stamp)]). pub type LinkScan = Arc, Option)>)>>>; +/// Stamps `file`, a linked path resolved by [`crate::doc_path`]; `None` (not looked at, like a +/// missing file) when it is not followed. #[cfg(not(target_arch = "wasm32"))] -fn stamp(path: &str) -> Option { - let meta = std::fs::metadata(path).ok()?; +fn stamp(file: Option<&std::path::Path>) -> Option { + let meta = std::fs::metadata(file?).ok()?; let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; Some((meta.len(), modified.as_nanos())) } #[cfg(target_arch = "wasm32")] -fn stamp(_: &str) -> Option { +fn stamp(_: Option<&std::path::Path>) -> Option { None } @@ -64,11 +66,24 @@ pub fn linked_files(d: &Document) -> Vec<(String, Vec)> { /// the layers of the ones that changed. pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let (doc, files) = (st.doc.id.0, resolved_files(st)); + let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); s.apply_link_stamps(doc, now) } +/// [`linked_files`] of `st`'s document with the file each path resolves to, or `None` for a path +/// that is not followed ([`crate::doc_path`]). +fn resolved_files(st: &crate::DocState) -> Vec<(String, Option, Vec)> { + let document = st.path.as_deref().map(std::path::Path::new); + linked_files(&st.doc) + .into_iter() + .map(|(p, ids)| { + let file = crate::doc_path::resolve_in(&p, document).ok(); + (p, file, ids) + }) + .collect() +} + impl Session { /// Start stamping the active document's linked files on a worker thread, unless a look is /// still running (or the build has no files to look at: the web app). @@ -77,14 +92,15 @@ impl Session { return; } let Some(st) = self.active() else { return }; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - if files.is_empty() { + let (doc, files) = (st.doc.id.0, resolved_files(st)); + // Nothing to look at: no links, or none that is followed. + if files.iter().all(|(_, file, _)| file.is_none()) { return; } let slot: LinkScan = Arc::default(); let out = slot.clone(); let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || { - let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let stamps = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); *out.lock().unwrap_or_else(PoisonError::into_inner) = Some((doc, stamps)); }); if spawned.is_ok() { diff --git a/crates/engine/src/mask_view_cmds.rs b/crates/engine/src/mask_view_cmds.rs index d7cfc338c0..403a6a0a47 100644 --- a/crates/engine/src/mask_view_cmds.rs +++ b/crates/engine/src/mask_view_cmds.rs @@ -160,7 +160,7 @@ pub fn specs() -> Vec { label: "Toggle Layer Mask Overlay", menu: &[], shortcut: Some("\\"), - params: r##"{"layer":id?} (shows or hides the active layer's mask as a 50% red rubylith over the composite, the `\` key; view state, not an undo step; painting while shown paints the mask)"##, + params: r##"{"layer":id?} (shows or hides the active layer's mask as a rubylith over the composite, the `\` key; red at 50% unless Layer Mask Display Options, channel.options {"channel":"mask"}, set it; view state, not an undo step; painting while shown paints the mask)"##, enabled: active_has_mask, run: toggle_overlay, journal: false, diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs index 18d2be8017..68fbffd25b 100644 --- a/crates/engine/src/migrate_cmds.rs +++ b/crates/engine/src/migrate_cmds.rs @@ -40,12 +40,14 @@ fn migrate(s: &mut Session, p: &Value) -> Result { let st = merge_groups(&mut ps.styles, groups(&v, "styles")); let sh = merge_groups(&mut ps.shapes, groups(&v, "shapes")); let pt = merge_groups(&mut ps.pattern_groups, groups(&v, "pattern_groups")); - // Tool presets are a flat list, matched by name. + // Tool presets are a flat list, matched by name. Read them like the launch loader does (entry by + // entry, checked names), and keep the library within its cap, so what we import survives a restart. let mut tp = 0usize; - if let Some(arr) = v.get("tool_presets").cloned() - && let Ok(incoming) = serde_json::from_value::>(arr) - { - for t in incoming { + if let Some(arr) = v.get("tool_presets").cloned() { + for t in crate::presets::tools::load(arr) { + if ps.tool_presets.len() >= crate::presets::tools::MAX_PRESETS { + break; + } if !ps.tool_presets.iter().any(|e| e.name == t.name) { ps.tool_presets.push(t); tp += 1; @@ -151,6 +153,46 @@ mod tests { let _ = std::fs::remove_file(file); } + #[test] + fn tool_presets_are_read_entry_by_entry_like_the_launch_loader() { + use crate::presets::tools::MAX_PRESETS; + let file = std::env::temp_dir().join(format!("pc-migrate-tools-{}.json", std::process::id())); + let migrate = |s: &mut Session, data: Value| { + std::fs::write(&file, serde_json::to_string(&data).unwrap()).unwrap(); + s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap() + }; + // A damaged entry no longer discards the good ones; blank and overlong names are skipped. + let mut s = Session::new(); + let before = s.presets.tool_presets.len(); + let r = migrate( + &mut s, + json!({"tool_presets": [ + {"name": "4x6 print", "tool": "crop", "options": {}}, + {"name": "old", "tool": null}, + {"name": " ", "tool": "crop"}, + {"name": "x".repeat(300), "tool": "crop"}, + {"name": " Padded ", "tool": "brush", "options": 5}, + ]}), + ); + assert_eq!(r["added"]["toolPresets"], 2); + assert_eq!(s.presets.tool_presets.len(), before + 2); + assert!(s.presets.tool_presets.iter().any(|t| t.name == "4x6 print")); + let padded = s.presets.tool_presets.iter().find(|t| t.name == "Padded").expect("trimmed name"); + assert_eq!(padded.options, json!({})); + // What was imported survives the next launch. + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.presets.tool_presets, s.presets.tool_presets); + // The library stays within MAX_PRESETS. + let mut s = Session::new(); + let many: Vec = (0..MAX_PRESETS + 500).map(|i| json!({"name": format!("p{i}"), "tool": "brush"})).collect(); + let before = s.presets.tool_presets.len(); + let r = migrate(&mut s, json!({"tool_presets": many})); + assert_eq!(s.presets.tool_presets.len(), MAX_PRESETS); + assert_eq!(r["added"]["toolPresets"], MAX_PRESETS - before); + let _ = std::fs::remove_file(file); + } + #[test] fn bad_path_is_an_error() { let mut s = Session::new(); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..180f940e91 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -63,7 +63,8 @@ pub fn rotated_size(size: Size, deg: f64) -> Size { pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.rotation.arbitrary"; - let angle = f64::from(num(p, "angle", 0.0)); + // At full precision: an f32 angle can grow the canvas 1 px past what `rotated_size` gives callers. + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(0.0); if !angle.is_finite() || angle.abs() > 3600.0 { return Err(bad(CMD, "angle out of range")); } @@ -75,15 +76,18 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { if deg.rem_euclid(360.0).abs() < 1e-9 { return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } - let bg = s.tools.background; + // The Background stays opaque, whatever the background colour's alpha. + let bg = [s.tools.background[0], s.tools.background[1], s.tools.background[2], 1.0]; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). + // They are pixel indices, so map the pixel's centre, as the warp samples the pixels. { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; let new = rotated_size(old, deg); let (sn, cs) = deg.to_radians().sin_cos(); let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); - s.set_sampler_map(Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }); + let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; + s.set_canvas_map(a, Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; @@ -108,7 +112,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?; let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); - base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); + // One shared tile per full tile, not a canvas-sized float buffer. + base.fill_rect(canvas, &fill); crate::transform_cmds::composite_over(&mut base, &rotated); base.prune(); *surf = base; @@ -388,9 +393,64 @@ fn color_table(s: &mut Session, p: &Value) -> Result { // ---------- Bitmap ---------- +/// Bitmap's Output resolution range (ppi): what a new document may have. +const BITMAP_RESOLUTION: std::ops::RangeInclusive = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION; + +/// Bytes per pixel of the Bitmap conversion's full-canvas working buffers at their peak (see +/// `bitmap`), on top of the document itself. +const BITMAP_WORK_BYTES_PER_PX: u64 = 40; + +/// Bitmap's Custom Pattern: a pattern's threshold map (see +/// [`quantize::custom_pattern_thresholds`]) with its width and height. +fn custom_pattern_map(s: &Session, cmd: &str, p: &Value) -> Result<(Vec, usize, usize)> { + if p.get("pattern").is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad(cmd, "`pattern` must be a pattern id or name")); + } + let pat = crate::pattern_cmds::resolve_param(s, cmd, p)?; + // A pattern keeps its own colours: its lightness is what it paints in a Grayscale document, + // over white where it is transparent. + let px = photocraft_compose::pattern::pattern_rgba(&pat, ColorMode::Grayscale); + if px.is_empty() { + return Err(bad(cmd, format!("the pattern \"{}\" is empty or too large", pat.display_name()))); + } + let lightness: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]]) * q[3] + (1.0 - q[3])).collect(); + Ok((quantize::custom_pattern_thresholds(&lightness), pat.width as usize, pat.height as usize)) +} + fn bitmap(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.bitmap"; - let dpi = s.active().ok_or(EngineError::NoDocument)?.doc.resolution_dpi.max(1.0); + let d = s.active().ok_or(EngineError::NoDocument)?; + let input = f64::from(d.doc.resolution_dpi.max(1.0)); + let (ow, oh) = (d.doc.size.width, d.doc.size.height); + // Output resolution (ppi): Photoshop resamples the image to it, keeping the print size, + // before converting, and the document takes it as its resolution. + let output = finite_num(p, CMD, "resolution", input)?; + if !BITMAP_RESOLUTION.contains(&output) { + return Err(bad(CMD, format!("resolution {output} is outside {}..{} pixels/inch", BITMAP_RESOLUTION.start(), BITMAP_RESOLUTION.end()))); + } + let (nw, nh) = if (output - input).abs() < 1e-6 { + (ow, oh) + } else { + // Keeping the print size can ask for more pixels than a document may have: refuse that + // rather than clamp, which would change the print size. + let max = crate::document_preset_cmds::MAX_DIMENSION; + let side = |v: u32| { + let n = (f64::from(v) * output / input).round().max(1.0); + if n > f64::from(max) { + return Err(bad(CMD, format!("{n} px across at {output} pixels/inch is more than {max} px; choose a lower Output resolution"))); + } + Ok(n as u32) + }; + (side(ow)?, side(oh)?) + }; + if (nw, nh) != (ow, oh) { + // Besides the resampled document, the conversion holds an RGBA f32 composite (16 B/px; 32 + // while it is laid over white), the gray plane (4) and the RGBA f32 output (16) at the new + // size: budget them all before allocating, not just the resample. + crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?; + } + let dpi = output as f32; + let mut custom = None; let method = match str_or(p, "method", "diffusion") { "threshold" | "threshold50" => BitmapMethod::Threshold, "pattern" => BitmapMethod::Pattern, @@ -403,21 +463,40 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, format!("angle {angle} is outside -180..180"))); } let shape = HalftoneShape::from_id(str_or(p, "shape", "round")).ok_or_else(|| bad(CMD, "shape: round|ellipse|line|square|diamond|cross"))?; + // The screen is laid out at the output resolution. BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: angle as f32, shape } } + "customPattern" => { + custom = Some(custom_pattern_map(s, CMD, p)?); + BitmapMethod::Threshold + } m => { - return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); + return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone|customPattern)"))); } }; + if (nw, nh) != (ow, oh) { + // Color Sampler points scale with the pixels, as in Image Size. + let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); + s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + } s.edit("Bitmap", |doc, active| { + if (nw, nh) != (doc.size.width, doc.size.height) { + crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); + } + doc.resolution_dpi = dpi; let px = composite(doc, true); let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); - quantize::to_bitmap(&mut gray, doc.size.width as usize, method); + let w = doc.size.width as usize; + match &custom { + Some((map, mw, mh)) => quantize::to_bitmap_custom(&mut gray, w, map, *mw, *mh), + None => quantize::to_bitmap(&mut gray, w, method), + } let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect(); single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true); Ok(()) })?; - Ok(Value::Null) + let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; + Ok(json!({"width": size.width, "height": size.height, "resolution": dpi})) } // ---------- Duotone ---------- @@ -444,6 +523,10 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result> { if let Some(pts) = ink.get("curve").and_then(Value::as_array) { let mut curve: Vec = pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect(); + // 1e39 is a valid JSON number but inf as f32, and a .pcraft with an inf curve can't be saved. + if curve.iter().any(|p| !p.input.is_finite() || !p.output.is_finite()) { + return Err(bad(cmd, "curve points must be finite numbers 0..100")); + } curve.sort_by(|a, b| a.input.total_cmp(&b.input)); if curve.len() >= 2 { d.curve = curve; @@ -523,7 +606,7 @@ pub fn specs() -> Vec { "image.mode.bitmap", "Bitmap…", ["Image", "Mode"], - r##"{"method":"diffusion|threshold|pattern|halftone"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round"} (halftone frequency in lines/inch; flattens)"##, + r##"{"resolution":1..30000=document,"method":"diffusion|threshold|pattern|halftone|customPattern"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round","pattern":id|name=Patterns panel selection} (resolution: Output in pixels/inch, resamples first; frequency/angle/shape: Halftone Screen, frequency in lines/inch; pattern: Custom Pattern; flattens)"##, |s| mode_is(s, &[ColorMode::Grayscale], "Bitmap needs a Grayscale image (Image › Mode › Grayscale first)"), bitmap ), diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..84d816a349 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,27 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_background_opaque_with_a_translucent_background_colour() { + let mut s = session(40, 20, 8, "rgb"); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 30})).unwrap(); + let corner = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0], "the uncovered corner is opaque background colour"); +} + +#[test] +fn rotate_arbitrary_keeps_the_full_precision_angle() { + // Rounded to f32 this angle crosses a whole pixel: 1003 px instead of 1002, so a rotated crop + // that sized its canvas with `rotated_size` at f64 would land 1 px off. + let angle = 0.114_706_514_493_370_71; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 1000})).unwrap(); + let r = s.execute("image.rotation.arbitrary", json!({"angle": angle})).unwrap(); + assert_eq!(rotated_size(Size::new(1000, 1000), angle), Size::new(1002, 1002)); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1002), Some(1002))); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); @@ -323,6 +344,163 @@ fn bitmap_halftone_rejects_bad_angles_and_frequencies_without_editing_the_docume assert_eq!(doc(&s).mode, ColorMode::Bitmap); } +/// A grayscale document `w` x `h` whose column x holds the 8-bit level `x` (a 0..255 ramp). +fn ramp(w: u32, h: u32, depth: u64, dpi: f32) -> Session { + let mut s = session(w, h, depth, "grayscale"); + paint(&mut s, |x, _| [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0]); + s.edit("dpi", |doc, _| { + doc.resolution_dpi = dpi; + Ok(()) + }) + .unwrap(); + s +} + +/// The first column of row `y` whose pixel is white among columns `x % m == k`. +fn first_white(d: &Document, y: i32, m: i32, k: i32) -> Option { + let surf = d.layers[0].surface().unwrap(); + (0..d.size.width as i32).filter(|x| x % m == k).find(|x| surf.pixel(*x, y)[0] > 0.5) +} + +fn add_pattern(s: &mut Session, name: &str, w: u32, h: u32, levels: &[u8]) { + let mut surf = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let data: Vec = levels.iter().map(|v| f32::from(*v) / 255.0).collect(); + surf.write_region(Rect::new(0, 0, w as i32, h as i32), &data); + let pat = photocraft_doc::pattern::Pattern::new(name, surf, w, h); + s.edit("pattern", |doc, _| { + doc.patterns.push(pat); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn bitmap_output_resolution_resamples_and_sets_the_resolution() { + // Photoshop: a 256 x 4 ramp at 72 ppi converted with Output 144 ppi is 512 x 8 at 144 ppi, + // its 50% threshold at column 256; at 36 ppi it is 128 x 2, the threshold at column 64. + for depth in [8, 16] { + let mut s = ramp(256, 4, depth, 72.0); + let r = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 144})).unwrap(); + assert_eq!(r, json!({"width": 512, "height": 8, "resolution": 144.0}), "{depth}"); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height, d.resolution_dpi, d.mode), (512, 8, 144.0, ColorMode::Bitmap), "{depth}"); + assert!(first_white(d, 3, 1, 0).is_some_and(|x| (255..=257).contains(&x)), "{depth}: {:?}", first_white(d, 3, 1, 0)); + // One step back is the grayscale document at its own size and resolution. + s.execute("edit.undo", json!({})).unwrap(); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height, d.resolution_dpi, d.mode), (256, 4, 72.0, ColorMode::Grayscale), "{depth}"); + s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 36})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (128, 2, 36.0), "{depth}"); + } + // Without `resolution`, the input resolution: the size stays. + let mut s = ramp(256, 4, 8, 300.0); + s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).resolution_dpi), (256, 300.0)); + assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128)); +} + +#[test] +fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() { + // The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the + // output equals the 72 ppi screen of the image upsampled first. + let mut a = ramp(64, 64, 8, 72.0); + a.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 10, "angle": 0, "resolution": 144})).unwrap(); + let mut b = ramp(64, 64, 8, 72.0); + b.execute("image.imageSize", json!({"resolution": 144, "resample": "bicubic", "keepPrintSize": true})).unwrap(); + b.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 10, "angle": 0})).unwrap(); + let (da, db) = (doc(&a), doc(&b)); + assert_eq!(da.size, db.size); + let (sa, sb) = (da.layers[0].surface().unwrap(), db.layers[0].surface().unwrap()); + let r = da.bounds(); + assert_eq!(sa.read_region(r), sb.read_region(r)); +} + +#[test] +fn bitmap_custom_pattern_thresholds_against_the_tiled_pattern() { + // Measured in Photoshop on a 0..255 ramp: a 7x1 pattern 0, 40 .. 240 turns column x white + // from level 237, 201, 164, 128, 92, 55, 19 (by x % 7): the lighter the pattern pixel, the + // sooner. A 1x3 pattern 30 / 220 / 120 does the same down the rows. + for depth in [8, 16] { + let mut s = ramp(256, 3, depth, 72.0); + add_pattern(&mut s, "Ramp 7", 7, 1, &[0, 40, 80, 120, 160, 200, 240]); + add_pattern(&mut s, "Rows 3", 1, 3, &[30, 220, 120]); + let r = s.execute("image.mode.bitmap", json!({"method": "customPattern", "pattern": "Ramp 7"})).unwrap(); + assert_eq!(r["width"], 256); + let d = doc(&s); + assert_eq!(d.mode, ColorMode::Bitmap); + let firsts: Vec = (0..7).map(|k| first_white(d, 0, 7, k).unwrap()).collect(); + // The first column at or past each level in its residue class. + let expect: Vec = [237, 201, 164, 128, 92, 55, 19].iter().enumerate().map(|(k, t)| (k as i32..256).step_by(7).find(|x| x >= t).unwrap()).collect(); + assert_eq!(firsts, expect, "{depth}"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({"method": "customPattern", "pattern": "Rows 3"})).unwrap(); + let rows: Vec = (0..3).map(|y| first_white(doc(&s), y, 1, 0).unwrap()).collect(); + assert_eq!(rows, [213, 43, 128], "{depth}"); + } +} + +#[test] +fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() { + let mut s = ramp(64, 4, 16, 72.0); + let before = s.active().unwrap().history.past_len(); + let bad = [ + json!({"resolution": 0}), + json!({"resolution": -72}), + json!({"resolution": 0.5}), + json!({"resolution": 30_001}), + json!({"resolution": 1e39}), + json!({"resolution": "high"}), + json!({"resolution": [72]}), + json!({"method": "customPattern", "pattern": "no such pattern"}), + json!({"method": "customPattern", "pattern": 7}), + json!({"method": "custom"}), + ]; + for p in bad { + assert!(s.execute("image.mode.bitmap", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{p}"); + let d = doc(&s); + assert_eq!((d.mode, d.size.width, d.resolution_dpi), (ColorMode::Grayscale, 64, 72.0), "{p}"); + } + // NaN can't be written in JSON; null is "not given" (the input resolution). + s.execute("image.mode.bitmap", json!({"resolution": null})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).resolution_dpi), (64, 72.0)); + s.execute("edit.undo", json!({})).unwrap(); + // The bounds themselves are fine. + s.execute("image.mode.bitmap", json!({"resolution": 1})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (1, 1, 1.0)); +} + +#[test] +fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() { + // 100 x 100 px at 1 ppi to 200 ppi is 20000 x 20000 px: 400 MP. Resampling an 8/16-bit gray + // document to it fits the resample budget (under 2 GB), but the conversion's f32 working + // buffers at that size (~40 B/px, 16 GB) don't: it is refused before anything is allocated. + for depth in [8, 16] { + let mut s = ramp(100, 100, depth, 1.0); + let d = &s.active().unwrap().doc; + assert!(crate::image_cmds::check_resample_budget("t", d, 20_000, 20_000, "").is_ok(), "{depth}"); + let before = s.active().unwrap().history.past_len(); + let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 200})).unwrap_err().to_string(); + assert!(e.contains("megapixels") && e.contains("Output resolution"), "{depth}: {e}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{depth}"); + let d = doc(&s); + assert_eq!((d.mode, d.size.width, d.resolution_dpi), (ColorMode::Grayscale, 100, 1.0), "{depth}"); + // A side past the 300,000 px document limit is refused, not clamped (that would change + // the print size): 20000 px at 72 ppi is 333,333 px at 1200 ppi. + let mut wide = ramp(20_000, 1, depth, 72.0); + let e = wide.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap_err().to_string(); + assert!(e.contains("300000 px"), "{depth}: {e}"); + assert_eq!((doc(&wide).mode, doc(&wide).size.width), (ColorMode::Grayscale, 20_000), "{depth}"); + // 18000 px to 300,000 px exactly is allowed. + let mut edge = ramp(18_000, 1, depth, 72.0); + edge.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap(); + assert_eq!(doc(&edge).size.width, 300_000, "{depth}"); + // A size that fits is still converted. + s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 3})).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).mode), (300, ColorMode::Bitmap), "{depth}"); + } +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); @@ -353,6 +531,20 @@ fn duotone_inks_and_display() { assert!(!session(4, 4, 8, "rgb").is_enabled("image.mode.duotone")); } +#[test] +fn duotone_curve_points_must_be_finite() { + let mut s = session(4, 4, 8, "gray"); + // 1e39 overflows f32 to inf, which the .pcraft writer refuses to save. + for curve in [json!([[0, 0], [100, 1e39]]), json!([[0, 0], [1e39, 50]]), json!([[-1e39, 0], [100, 100]])] { + let r = s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": curve}]})); + assert!(r.is_err(), "{curve} accepted"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); + } + s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": [[0, 0], [50, 70], [100, 100]]}]})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Duotone); + photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); +} + #[test] fn modes_round_trip_through_pcraft() { let mut s = session(12, 12, 8, "rgb"); diff --git a/crates/engine/src/multichannel_cmds.rs b/crates/engine/src/multichannel_cmds.rs index a6e74101b1..b0d46c2e4f 100644 --- a/crates/engine/src/multichannel_cmds.rs +++ b/crates/engine/src/multichannel_cmds.rs @@ -19,6 +19,7 @@ use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; use photocraft_raster::Surface; use serde_json::{Value, json}; +use crate::channel_cmds::ChannelTarget; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; @@ -119,6 +120,9 @@ fn inks_of(doc: &Document) -> Vec<(String, Color, Vec)> { } fn to_multichannel(s: &mut Session, _p: &Value) -> Result { + let Some((old, view)) = s.active().map(|st| (st.doc.channels.len(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) else { + return Err(EngineError::NoDocument); + }; let names = s.edit("Multichannel", |doc, active| { let inks = inks_of(doc); let mut channels: Vec = inks @@ -143,15 +147,45 @@ fn to_multichannel(s: &mut Session, _p: &Value) -> Result { doc.icc_profile = None; Ok(inks.into_iter().map(|(n, _, _)| n).collect::>()) })?; + // The alpha channels moved behind the inks. + let map: Vec> = (0..old).map(|i| Some(i + names.len())).collect(); + remap_view(s, view, &map); Ok(json!({ "mode": "multichannel", "channels": names })) } +/// Keep the Channels target and eyes on the same channels after a conversion moved them: +/// `view` is the target and alpha eyes before the conversion, `map[i]` the new index of old +/// channel `i`, `None` when it left (a targeted ink becomes the composite). +fn remap_view(s: &mut Session, (target, eyes): (ChannelTarget, Vec), map: &[Option]) { + let Some(st) = s.active_mut() else { return }; + let mut visible = vec![false; st.doc.channels.len()]; + for (&on, to) in eyes.iter().zip(map) { + if let (true, Some(slot)) = (on, to.and_then(|j| visible.get_mut(j))) { + *slot = true; + } + } + let v = &mut st.channel_view; + v.alpha_visible = visible; + if let ChannelTarget::Alpha(t) = target { + v.target = map.get(t).copied().flatten().map_or(ChannelTarget::Composite, ChannelTarget::Alpha); + } + crate::channel_cmds::fix_view(st); +} + /// Image › Mode › RGB / CMYK / Lab / Grayscale from a Multichannel document (called by /// `color_cmds::convert_mode`). Profiles are not involved: Multichannel has none. pub(crate) fn convert_from(s: &mut Session, mode: ColorMode, _p: &Value) -> Result { if !matches!(mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab | ColorMode::Grayscale) { return Err(EngineError::Other(format!("a Multichannel document can't be converted to {mode:?}"))); } + // The inks leave; the other channels close up. + let Some((spots, view)) = s + .active() + .map(|st| (st.doc.channels.iter().map(|c| c.spot.is_some()).collect::>(), (st.channel_view.target, st.channel_view.alpha_visible.clone()))) + else { + return Err(EngineError::NoDocument); + }; + let map: Vec> = spots.iter().enumerate().map(|(i, &spot)| (!spot).then(|| spots.iter().take(i).filter(|&&sp| !sp).count())).collect(); let how = s.edit("Mode Change", |doc, active| { let (inks, others): (Vec, Vec) = std::mem::take(&mut doc.channels).into_iter().partition(|c| c.spot.is_some()); let canvas = doc.bounds(); @@ -195,6 +229,7 @@ pub(crate) fn convert_from(s: &mut Session, mode: ColorMode, _p: &Value) -> Resu doc.channels = others; Ok(if reinterpret { "channels" } else { "appearance" }) })?; + remap_view(s, view, &map); Ok(json!({ "mode": format!("{mode:?}").to_lowercase(), "from": "multichannel", "converted": how })) } diff --git a/crates/engine/src/multichannel_cmds/tests.rs b/crates/engine/src/multichannel_cmds/tests.rs index e8df57498b..f96ff48e95 100644 --- a/crates/engine/src/multichannel_cmds/tests.rs +++ b/crates/engine/src/multichannel_cmds/tests.rs @@ -119,6 +119,33 @@ fn flattens_layers_and_keeps_alpha_channels() { assert!(doc(&s).layers[0].surface().unwrap().pixel(1, 1)[0] < 0.01); } +#[test] +fn targeted_alpha_channel_stays_targeted_across_conversions() { + use crate::channel_cmds::ChannelTarget; + let mut s = session("rgb", 8); + s.execute("channel.new", json!({})).unwrap(); + let view = |s: &Session| s.active().unwrap().channel_view.clone(); + assert_eq!(view(&s).target, ChannelTarget::Alpha(0)); + assert_eq!(view(&s).alpha_visible, [true]); + s.execute("image.mode.multichannel", json!({})).unwrap(); + // The alpha channel now follows the three inks. + assert_eq!(view(&s).target, ChannelTarget::Alpha(3)); + assert_eq!(view(&s).alpha_visible, [false, false, false, true]); + let cyan = doc(&s).channels[0].surface.pixel(4, 3)[0]; + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + assert_eq!(doc(&s).channels[0].surface.pixel(4, 3)[0], cyan, "the fill must not touch the Cyan plate"); + assert_eq!(doc(&s).channels[3].name, "Alpha 1"); + // Back to RGB: the inks leave, the alpha channel is index 0 again. + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(view(&s).target, ChannelTarget::Alpha(0)); + assert_eq!(view(&s).alpha_visible, [true]); + // A targeted ink becomes the composite. + s.execute("image.mode.multichannel", json!({})).unwrap(); + s.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(view(&s).target, ChannelTarget::Composite); +} + #[test] fn mismatched_channel_count_converts_the_printed_look() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 54bbd60b7a..2f589f3390 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -40,10 +40,12 @@ fn list_json(d: &Document) -> Value { json!({"notes": d.notes.iter().enumerate().map(|(i, n)| note_json(i, n)).collect::>()}) } -fn color_of(p: &Value) -> Option { - p.get("color")?; - let c = crate::commands::color_param(p, "color", [1.0, 1.0, 0.51, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value) -> Result> { + if p.get("color").is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn index_of(s: &Session, p: &Value, cmd: &str) -> Result { @@ -68,7 +70,7 @@ fn add(s: &mut Session, p: &Value) -> Result { modified: pdf_date(), ..Default::default() }; - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } let i = s.edit("New Note", |d, _| { @@ -90,7 +92,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("author").and_then(Value::as_str) { n.author = a.to_string(); } - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } if let Some(o) = p.get("open").and_then(Value::as_bool) { @@ -106,6 +108,9 @@ fn set(s: &mut Session, p: &Value) -> Result { n.position = [nx, ny]; // The popup travels with its icon. n.popup = [n.popup[0] + dx, n.popup[1] + dy, n.popup[2] + dx, n.popup[3] + dy]; + if !n.popup.iter().all(|v| v.is_finite()) { + return Err(bad(CMD, "the move is too far: the note popup would leave the representable range")); + } } if n == d.notes[i] { return Ok(json!({"index": i, "note": note_json(i, &n), "changed": false})); @@ -279,6 +284,19 @@ mod tests { assert!(s.execute("notes.set", json!({"index": 0})).is_err()); } + #[test] + fn huge_move_is_rejected_and_document_stays_saveable() { + let mut s = session(); + s.execute("notes.add", json!({"x": 1.7e308, "y": 0})).unwrap(); + let before = s.active().unwrap().doc.notes[0].clone(); + let past = s.active().unwrap().history.past_len(); + assert!(s.execute("notes.set", json!({"index": 0, "x": -1.7e308})).is_err()); + let a = s.active().unwrap(); + assert_eq!(a.doc.notes[0], before); + assert_eq!(a.history.past_len(), past); + assert!(photocraft_format::save_to_bytes(&a.doc, &Default::default()).is_ok()); + } + #[test] fn bad_params() { let mut s = session(); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 497d7f4fb1..4255d796c4 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -63,7 +63,7 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?; let tile = photocraft_compose::pattern::Tile::new(&pat) .ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty or too large to paint".into() })?; - let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); + let (scale, angle, _, phase) = crate::pattern_cmds::placement("paint.bucket", p)?; Some((tile, scale, angle, phase)) } else { None diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 0cb9052ceb..95163d39f5 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -300,34 +300,49 @@ fn export(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "count": pats.len(), "bytes": bytes.len()})) } -pub(crate) fn placement(p: &Value) -> (f32, f32, bool, (f32, f32)) { - let num = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32; - let phase = p - .get("phase") - .and_then(Value::as_array) - .map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32)); - ((num("scale", 100.0) / 100.0).clamp(0.01, 10.0), num("angle", 0.0), p.get("link").and_then(Value::as_bool).unwrap_or(true), phase) +/// A pattern's scale, angle, link and phase from `p`. The angle is normalised to (-180, 180]; +/// a non-finite angle or a phase beyond ±`MAX_PHASE` is an error (it would render NaN). +pub(crate) fn placement(cmd: &str, p: &Value) -> Result<(f32, f32, bool, (f32, f32))> { + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32; + let max = crate::pattern_fill_cmds::MAX_PHASE; + let a = p.get("phase").and_then(Value::as_array); + let c = |i: usize| { + let v = a.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(0.0); + (v.is_finite() && v.abs() <= max).then_some(v as f32).ok_or_else(|| bad(cmd, format!("`phase` needs finite coordinates within ±{max}"))) + }; + let phase = (c(0)?, c(1)?); + let angle = crate::gradient_fill_cmds::angle_param(cmd, p, 0.0)?; + Ok(((scale / 100.0).clamp(0.01, 10.0), angle, p.get("link").and_then(Value::as_bool).unwrap_or(true), phase)) } /// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks, /// above `active`. The Pattern Fill dialog previews it on a copy of the document. -pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option, pat: &Pattern, p: &Value) -> Result { - let (scale, angle, link, phase) = placement(p); +pub(crate) fn insert_fill_layer( + doc: &mut Document, + active: &mut Option, + pat: &Pattern, + p: &Value, + default_mask: bool, +) -> Result { + let (scale, angle, link, phase) = placement("layer.newFillLayer.pattern", p)?; ensure_in_doc(doc, pat); 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")?; + crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; let id = doc.insert_above(*active, l); *active = Some(id); Ok(id) } +/// `layer.newFillLayer.pattern`: a Pattern Fill layer above the active one, masked as the Layers panel's Use Default Masks on Fill Layers option says. fn new_fill_layer(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?; - let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?; + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?; Ok(json!({"layer": id.0, "pattern": pat.id})) } +/// `brush.texturePattern`: use a pattern as the brush texture, converted to grey as the luminance of its composite over white. fn brush_texture(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "brush.texturePattern", p)?; let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten(); @@ -535,6 +550,26 @@ mod tests { } } + #[test] + fn pattern_placement_rejects_non_finite_angle_and_phase() { + // 1e39 overflowed f32 to infinity: a NaN render, and a fill layer .pcraft refused to save. + let mut s = session(8); + let n = s.active().unwrap().doc.layers.len(); + for cmd in ["layer.newFillLayer.pattern", "edit.fill", "paint.bucket"] { + for bad in [json!({"angle": 1e39}), json!({"angle": "x"}), json!({"phase": [1e39, 0]}), json!({"phase": [0, -1e300]})] { + let mut p = json!({"pattern": "Checkerboard", "contents": "pattern", "x": 1, "y": 1}); + p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(s.execute(cmd, p).is_err(), "{cmd} {bad}"); + } + } + assert_eq!(s.active().unwrap().doc.layers.len(), n); + // The angle is normalised to (-180, 180] like the Pattern Fill dialog's. + let r = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard", "angle": 270, "phase": [3, -4]})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + let Some(LayerContent::Fill(Fill::Pattern { angle, phase, .. })) = s.active().unwrap().doc.layer(id).map(|l| &l.content) else { panic!() }; + assert_eq!((*angle, *phase), (-90.0, (3.0, -4.0))); + } + #[test] fn fill_with_pattern_respects_selection() { for depth in [8u64, 16, 32] { @@ -557,7 +592,7 @@ mod tests { s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); s.execute("layer.layerStyle.patternOverlay", json!({"pattern": "Diagonal Lines"})).unwrap(); - s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#00ff00", "add": true})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#00ff00", "add": true})).unwrap(); let out = pixel(&s, 8, 15); assert!(out[1] > 0.9 && out[0] < 0.1, "stroke outside: {out:?}"); } diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index b9d75e6cb5..a599ca1ec0 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -14,7 +14,7 @@ pub const CREATE: &str = "layer.newFillLayer.pattern"; /// Photoshop's Scale range in the Pattern Fill dialog (percent). pub const SCALE_RANGE: (f64, f64) = (1.0, 1000.0); /// Largest phase (origin offset) accepted, in pixels. -const MAX_PHASE: f64 = 1.0e7; +pub(crate) const MAX_PHASE: f64 = 1.0e7; fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: SET.into(), msg: msg.into() } @@ -124,13 +124,15 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { CREATE => { let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?; let mut active = st.active_layer; - crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?; + let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); + crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?; } other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }), } Ok(doc) } +/// Register `layer.fill.pattern.set` (the Pattern Fill dialog's command), journaled like any other edit. pub fn specs() -> Vec { vec![CommandSpec { id: SET, @@ -216,6 +218,40 @@ mod tests { assert!(preview(&s, "edit.fill", &json!({})).is_err()); } + /// At 8, 16 and 32 bits the Pattern Fill preview and the committed layer carry the default mask only while the option is on, and creating one is one undoable step. + #[test] + fn create_preview_and_commit_honor_default_masks_option() { + for depth in [8, 16, 32] { + for default_mask in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.edit_prefs(|prefs| { + prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask})); + }); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.entries().len(); + let p = json!({"pattern": "Grid", "scale": 50}); + let shown = preview(&s, CREATE, &p).unwrap(); + assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); + assert_eq!(s.active().unwrap().history.entries().len(), count); + let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap(); + assert_eq!(previewed.mask.is_some(), default_mask); + if let Some(mask) = &previewed.mask { + assert_eq!(mask.surface.format(), photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, shown.depth, false)); + } + let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap()); + let committed = s.active().unwrap().doc.layer(id).unwrap(); + assert_eq!(committed.mask, previewed.mask); + assert_eq!(committed.content, previewed.content); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask); + } + } + } + + /// Malformed or out-of-range params, and missing layers or patterns, are errors in both the command and its preview, never panics. #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 8acaf42ce2..fbff5c6e22 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -188,7 +188,8 @@ fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> { let (w, ht) = size; let clip = Rect::from_xywh(0, 0, w, ht); - let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg); + // The Background stays opaque, whatever the background colour's alpha. + let bg = photocraft_raster::from_rgba(&doc.pixel_format(), [bg[0], bg[1], bg[2], 1.0]); let background_id = doc.background().map(|l| l.id); warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?; for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { @@ -201,6 +202,18 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32) Ok(()) } +/// Color Sampler points (pixel indices) through `h`, as Photoshop maps them: each pixel's centre +/// goes where the crop takes it, and the points outside the quad, now off the canvas, are dropped. +fn map_samplers(points: &[[f64; 2]], h: &Homography, (w, ht): (u32, u32)) -> Vec<[f64; 2]> { + let bounds = Rect::from_xywh(0, 0, w, ht); + points + .iter() + .map(|p| h.apply(p[0] + 0.5, p[1] + 0.5)) + .map(|(x, y)| [x - 0.5, y - 0.5]) + .filter(|p| p[0].is_finite() && p[1].is_finite() && bounds.contains(p[0].floor() as i32, p[1].floor() as i32)) + .collect() +} + fn perspective_crop(s: &mut Session, p: &Value) -> Result { let q = corners(p)?; let (w, h) = out_size(p, &q)?; @@ -215,6 +228,10 @@ fn perspective_crop(s: &mut Session, p: &Value) -> Result { .any(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Fill(_) | LayerContent::Smart(_))); let map = quad_to_rect(&q, f64::from(w), f64::from(h)).ok_or_else(|| bad("the corners don't define a perspective"))?; let bg = s.tools.background; + // The Color Sampler points follow their pixels through the homography, which the edit's + // affine sampler map can't express: mapped here once the crop has gone through (#7760). + let samplers = s.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); + let removed = s.samplers_removed; crate::analysis_cmds::compound(s, "Perspective Crop", |s| { if rasterize { crate::extra_cmds::rasterize_all(s)?; @@ -227,6 +244,11 @@ fn perspective_crop(s: &mut Session, p: &Value) -> Result { Ok(()) }) })?; + let kept = map_samplers(&samplers, &map, (w, h)); + s.samplers_removed = removed + (samplers.len() - kept.len()); + if let Some(d) = s.active_mut() { + d.color_samplers = kept; + } let dpi = s.active().map(|d| d.doc.resolution_dpi); Ok(json!({"width": w, "height": h, "resolution": dpi, "corners": q})) } @@ -298,10 +320,61 @@ mod tests { } } + /// #7760: Color Sampler points move with the image through the crop, as in Photoshop, so they + /// keep reading the same content; points outside the quad are deleted, also those whose old + /// coordinates lie inside the smaller new canvas. + #[test] + fn color_samplers_follow_the_perspective_crop() { + let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]]; + let (w, h) = (100.0, 80.0); + let mut s = skewed_ramp(SampleType::U16, &q, w, h); + s.active_mut().unwrap().color_samplers = vec![[70.0, 60.0], [5.0, 5.0], [120.0, 40.0]]; + s.execute(CMD, json!({"corners": q, "width": w, "height": h})).unwrap(); + let to_out = Homography::rect_to_quad([0.0, 0.0, w, h], q).unwrap().inverse().unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.color_samplers.len(), 2, "(5, 5) lies outside the quad: {:?}", st.color_samplers); + for (p, old) in st.color_samplers.iter().zip([[70.0, 60.0], [120.0, 40.0]]) { + let (u, v) = to_out.apply(old[0] + 0.5, old[1] + 0.5); + assert!((p[0] + 0.5 - u).abs() < 1e-6 && (p[1] + 0.5 - v).abs() < 1e-6, "{old:?} -> {p:?}, want ({u:.2}, {v:.2})"); + let px = st.doc.layers[0].surface().unwrap().rgba(p[0].round() as i32, p[1].round() as i32); + assert!((f64::from(px[0]) * w - u).abs() < 1.5 && (f64::from(px[1]) * h - v).abs() < 1.5, "{old:?} reads {px:?}"); + } + assert_eq!(s.take_samplers_removed(), 1); + } + + /// Corners past the canvas: the uncovered Background is the background colour, opaque even + /// when that colour has alpha (the Background stays opaque). + #[test] + fn uncovered_background_is_opaque() { + let q = [[-20.0, -20.0], [180.0, -20.0], [180.0, 140.0], [-20.0, 140.0]]; + let mut s = skewed_ramp(SampleType::U8, &q, 100.0, 80.0); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute(CMD, json!({"corners": q, "width": 100, "height": 80})).unwrap(); + let corner = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); + } + + /// #7557: a quad low on a strongly converging photo (its horizon crosses the canvas, with the + /// canvas centre beyond it) is cropped, not left blank in the background colour. + #[test] + fn a_quad_below_a_horizon_across_the_canvas_keeps_its_content() { + let mut doc = Document::with_background("red", Size::new(100, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute(CMD, json!({"corners": [[40, 70], [60, 70], [90, 95], [10, 95]], "width": 50, "height": 39})).unwrap(); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(25, 20); + assert_eq!(px, [1.0, 0.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { let mut s = skewed_ramp(SampleType::U8, &[[0.0, 0.0], [160.0, 0.0], [160.0, 120.0], [0.0, 120.0]], 160.0, 120.0); + // A rejected crop leaves the Color Sampler points and the removed count alone. + let samplers = vec![[5.0, 5.0], [150.0, 110.0]]; + s.active_mut().unwrap().color_samplers = samplers.clone(); + s.samplers_removed = 2; for p in [ json!({}), json!({"corners": [[0, 0], [10, 0], [10, 10]]}), @@ -319,5 +392,7 @@ mod tests { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } assert_eq!(s.active().unwrap().doc.size, Size::new(160, 120)); + assert_eq!(s.active().unwrap().color_samplers, samplers); + assert_eq!(s.take_samplers_removed(), 2); } } diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 09fd474aad..222866fdd0 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -402,10 +402,18 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam .collect() }) .collect(); - // Shift ROIs to canvas-relative coordinates for the blend. - let local: Vec = rois.iter().map(|r| RoiImage { x0: r.x0 - canvas.x0, y0: r.y0 - canvas.y0, ..r.clone() }).collect(); + // Shift ROIs to canvas-relative coordinates for the blend, and back after: a shifted copy + // would hold every source's pixels twice (#7758). + for r in &mut rois { + r.x0 -= canvas.x0; + r.y0 -= canvas.y0; + } let levels = if seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; - let (blend, cov) = panorama::multiband(cw, chh, &local, &weights, levels); + let (blend, cov) = panorama::multiband(cw, chh, &rois, &weights, levels); + for r in &mut rois { + r.x0 += canvas.x0; + r.y0 += canvas.y0; + } let cc = fmt.mode.color_channels(); let nch = fmt.channels(); let mut composite = Surface::new(fmt); @@ -799,7 +807,11 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result { } let mut surf = Surface::new(fmt); surf.write_region(area, &data); - doc.layers.push(Layer::new("Background", LayerContent::Raster(surf))); + // The merged result is opaque: a real Background, as Crop and Straighten makes. + let mut bg = Layer::new("Background", LayerContent::Raster(surf)); + bg.locks.transparency = true; + bg.locks.position = true; + doc.layers.push(bg); let idx = s.add_document(doc, None); let ev0 = exposures[order[0]]; Ok(json!({ @@ -859,6 +871,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { let mut nd = Document::new(format!("{} copy {}", doc.name, k + 1), Size::new(pw, ph), doc.mode, doc.depth); nd.icc_profile = doc.icc_profile.clone(); nd.resolution_dpi = doc.resolution_dpi; + nd.duotone = doc.duotone.clone(); let mut bg = Layer::new("Background", LayerContent::Raster(px)); bg.locks.transparency = true; bg.locks.position = true; @@ -871,8 +884,13 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { // ---------- specs ---------- -fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents. +fn not_indexed(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.mode == ColorMode::Indexed { + return Err("not available for Indexed Color documents".into()); + } + Ok(()) } fn always(_: &Session) -> std::result::Result<(), String> { @@ -907,7 +925,7 @@ pub fn specs() -> Vec { menu: &["File", "Automate"], shortcut: None, params: "{} → one new document per photo found on the scan", - enabled: has_doc, + enabled: not_indexed, run: crop_and_straighten, journal: true, }, @@ -1080,6 +1098,7 @@ mod tests { assert!((ev[1] - 2.0).abs() < 1e-6 && (ev[2] - 4.0).abs() < 1e-6, "{ev:?}"); let d = &s.active().unwrap().doc; assert_eq!(d.depth, SampleType::F32); + assert!(d.is_background(d.layers[0].id), "the merged layer is a real (locked) Background"); let l = d.layers[0].surface().unwrap(); // Linear output: ratios across the ramp follow the radiance. let (a, b) = (l.rgba(20, 30)[1] as f64, l.rgba(80, 30)[1] as f64); @@ -1106,6 +1125,54 @@ mod tests { if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } } + /// A light scan with one dark tilted print on it. + fn scan_with_one_photo() -> Document { + let (w, h) = (300usize, 220usize); + let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h]; + let (s, co) = 5.0f64.to_radians().sin_cos(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f64 + 0.5 - 150.0, y as f64 + 0.5 - 110.0); + let (u, v) = (co * dx + s * dy, -s * dx + co * dy); + if u.abs() < 80.0 && v.abs() < 55.0 { + px[y * w + x] = [0.2 + (u / 160.0) as f32 * 0.3, 0.25, 0.3, 1.0]; + } + } + } + doc_from("scan", &px, w, h, SampleType::U8) + } + + #[test] + fn crop_and_straighten_keeps_duotone_inks() { + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute( + "image.mode.duotone", + json!({"type": "duotone", "inks": [{"name": "Black", "color": [0, 0, 0]}, {"name": "Orange", "color": [255, 128, 0]}]}), + ) + .unwrap(); + let src = s.documents()[0].doc.duotone.clone(); + assert!(src.is_some()); + let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap(); + assert_eq!(r["photos"].as_array().unwrap().len(), 1, "{r}"); + let d = &s.documents()[1].doc; + assert_eq!(d.mode, ColorMode::Duotone); + assert_eq!(d.duotone, src); + } + + #[test] + fn crop_and_straighten_disabled_for_indexed() { + // Photoshop greys the command out for Indexed Color documents. + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_eq!(s.documents()[0].doc.mode, ColorMode::Indexed); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos")); + assert!(s.execute("file.automate.cropAndStraightenPhotos", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); + } + #[test] fn crop_and_straighten_makes_documents() { let (w, h) = (500usize, 360usize); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index b046de03bd..dc4e531f53 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -239,16 +239,21 @@ pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], sel let fmt = s.format(); // Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept). let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32); - let solid = opaque.then(|| { - let mut enc = [0.0f32; 8]; - let n = photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc); - s.solid_tile(enc.get(..n).unwrap_or(&[])) - }); + let mut enc = [0.0f32; 8]; + let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc)); + let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[]))); let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone())); edit_tiles(s, area, selection, "filling pixels", whole, |k, px| { if k <= 0.0 { return false; } + if k >= 1.0 + && let Some(m) = enc_len + && let Some(src) = enc.get(..px.len().min(m)) + { + px.copy_from_slice(src); + return true; + } fill_pixel(&fmt, px, color, k, lock_transparency); true }) @@ -294,9 +299,31 @@ pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<& px.fill(0.0); } true - })?; - s.prune(); - Ok(()) + }) +} + +/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its +/// selection coverage k of alpha, down to none, so a selection made from the layer's own +/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost. +pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> { + let fmt = s.format(); + let n = fmt.channels(); + if !fmt.alpha { + return Ok(()); + } + let empty_default = s.default_bytes().iter().all(|&b| b == 0); + let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop); + edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| { + if k <= 0.0 { + return false; + } + let Some(a) = px.get_mut(n - 1) else { return false }; + *a = (*a - k).max(0.0); + if *a <= 0.0 { + px.fill(0.0); + } + true + }) } /// A whole-tile shortcut of [`edit_tiles`]. @@ -331,6 +358,7 @@ fn edit_tiles( coords.extend(area.tiles()); // Untouched pixels read as the default where the surface has no tile. let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None }; + let dp = s.default_bytes(); let surf = &*s; let edits = coords .into_par_iter() @@ -371,7 +399,11 @@ fn edit_tiles( } } } - Ok(out.map(|t| (tc, Some(Arc::new(t))))) + Ok(match out { + Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)), + Some(t) => Some((tc, Some(Arc::new(t)))), + None => None, + }) }) .collect::>>()?; for (tc, tile) in edits.into_iter().flatten() { diff --git a/crates/engine/src/plugin_cmds.rs b/crates/engine/src/plugin_cmds.rs index 5c7d2dd71a..6e5ff3dff9 100644 --- a/crates/engine/src/plugin_cmds.rs +++ b/crates/engine/src/plugin_cmds.rs @@ -47,16 +47,56 @@ fn plugin_params(p: &Value) -> Value { return v.clone(); } match p { - Value::Object(m) => Value::Object( - m.iter() - .filter(|(k, _)| !matches!(k.as_str(), "id" | "layer" | "target" | "channel") && !k.starts_with("__")) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ), + Value::Object(m) => Value::Object(m.iter().filter(|(k, _)| !command_key(k)).map(|(k, v)| (k.clone(), v.clone())).collect()), _ => Value::Object(Map::new()), } } +/// A top-level `plugin.run` key the command itself uses (or a dialog's private `__` key), not a +/// plug-in parameter. +fn command_key(k: &str) -> bool { + matches!(k, "id" | "layer" | "target" | "channel") || k.starts_with("__") +} + +/// Proxy factor for a live preview of `plugin.run` with `p` on a document reduced `k` times: the +/// plug-in's [`photocraft_plugins::Manifest::preview_factor`] (its `"unit": "px"` parameters set +/// the limit, not the built-in filters' parameter names). `k` when the plug-in isn't installed. +pub fn preview_factor(p: &Value, k: u32) -> u32 { + let Ok(plugin) = plugin_by_id(RUN, p) else { return k }; + match plugin_params(p) { + Value::Object(params) => plugin.manifest().preview_factor(¶ms, k), + // `null` means the defaults (see `Manifest::resolve_params`). + Value::Null => plugin.manifest().preview_factor(&Map::new(), k), + _ => k, + } +} + +/// Rewrites `plugin.run` params `p` for a live preview on a copy of the document reduced `k` +/// times: the plug-in's pixel-sized parameters (`"unit": "px"`, under `params` or at the top +/// level, as [`plugin_params`] reads them) are divided by `k`, so the preview shows the lengths OK +/// applies (#7524). Nothing changes when the plug-in isn't installed (the run then fails as usual) +/// or for values of the wrong type (the run rejects them). +pub fn scale_for_preview(p: &mut Value, k: u32) { + if k <= 1 { + return; + } + let Ok(plugin) = plugin_by_id(RUN, p) else { return }; + let nested = p.get("params").is_some(); + let mut params = match plugin_params(p) { + Value::Object(params) => params, + Value::Null => Map::new(), + _ => return, + }; + plugin.manifest().scale_for_preview(&mut params, k); + let Value::Object(o) = p else { return }; + if nested { + o.insert("params".into(), Value::Object(params)); + } else { + // Top-level form: write back only plug-in keys, never the command's own (`id`, `channel`…). + o.extend(params.into_iter().filter(|(key, _)| !command_key(key))); + } +} + fn plugin_by_id(cmd: &str, p: &Value) -> Result> { let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?; registry::get(id).ok_or_else(|| err(photocraft_plugins::Error::NotFound(id.into()))) @@ -77,10 +117,17 @@ fn run(s: &mut Session, p: &Value) -> Result { *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; return Ok(()); } + crate::commands::check_pixels_unlocked(doc, layer)?; + let keep_alpha = doc.effective_locks(layer).transparency; let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; match &mut l.content { LayerContent::Raster(surf) => { + // Lock Transparency: the plug-in may change colour but not alpha (as built-in filters). + let before = keep_alpha.then(|| surf.clone()); *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + if let Some(before) = &before { + crate::filters::keep_alpha(before, surf, &|| false)?; + } Ok(()) } LayerContent::Smart(_) => { diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs index 3d178fada9..7c6ed635aa 100644 --- a/crates/engine/src/plugin_cmds/tests.rs +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -155,6 +155,112 @@ fn params_are_validated_and_passed() { Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap(); } +/// #7524: a reduced live preview divides the plug-in's `"unit": "px"` params by the reduction, in +/// both param shapes, and leaves unitless params (even ones named like built-in pixel params) and +/// the command's own keys alone. +#[test] +fn preview_scales_the_plugin_pixel_params() { + let id = "test.cmds.px"; + let params = concat!( + r#"{"amount":{"type":"number","min":0,"max":100,"default":12,"unit":"px"},"#, + r#""radius":{"type":"int","min":2,"max":50,"default":10,"unit":"px"},"#, + r#""size":{"type":"number","min":0,"max":100,"default":50}}"# + ); + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, params, GREY))})).unwrap(); + let list = Session::new().execute("plugin.list", json!({})).unwrap(); + let me = list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap().clone(); + assert_eq!(me["paramsSchema"]["amount"]["unit"], "px"); + + // Top level (the dialog's shape): px params scaled, `size` and the command's keys untouched. + let mut p = json!({"id": id, "amount": 12, "radius": 10, "size": 40, "channel": 1, "__preview": true}); + scale_for_preview(&mut p, 4); + assert_eq!(p, json!({"id": id, "amount": 3.0, "radius": 3, "size": 40, "channel": 1, "__preview": true})); + // Nested: scaled inside `params`, missing ones from their defaults. + let mut p = json!({"id": id, "params": {"size": 40}}); + scale_for_preview(&mut p, 2); + assert_eq!(p, json!({"id": id, "params": {"amount": 6.0, "radius": 5, "size": 40}})); + let mut p = json!({"id": id, "params": null}); + scale_for_preview(&mut p, 2); + assert_eq!(p["params"], json!({"amount": 6.0, "radius": 5})); + // The scaled params still run. + doc(8).execute("plugin.run", p).unwrap(); + // Full resolution, an unknown plug-in or bad shapes change nothing and never panic. + for (mut p, k) in [ + (json!({"id": id, "amount": 12}), 1), + (json!({"id": "test.cmds.none", "amount": 12}), 4), + (json!({"amount": 12}), 4), + (json!({"id": id, "params": [1]}), 4), + (json!([1, 2]), 4), + ] { + let before = p.clone(); + scale_for_preview(&mut p, k); + assert_eq!(p, before); + } + // A value of the wrong type is kept for the run to reject. + let mut p = json!({"id": id, "amount": "lots", "radius": 10}); + scale_for_preview(&mut p, 4); + assert_eq!(p, json!({"id": id, "amount": "lots", "radius": 3})); + assert!(doc(8).execute("plugin.run", p).is_err()); + + // The factor: radius (min 2) 10 allows 4, 5 allows 2; unitless and missing plug-ins don't limit it. + assert_eq!(preview_factor(&json!({"id": id, "radius": 10}), 4), 4); + assert_eq!(preview_factor(&json!({"id": id, "radius": 5, "size": 0.5}), 4), 2); + assert_eq!(preview_factor(&json!({"id": id, "params": {"radius": 3}}), 4), 1); + assert_eq!(preview_factor(&json!({"id": id}), 8), 5, "the default radius"); + assert_eq!(preview_factor(&json!({"id": "test.cmds.none", "radius": 3}), 4), 4); + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); +} + +#[test] +fn locked_layers_refuse_plugin_filters() { + // Like built-in filters: locked pixels (or Lock All) refuse the run with no undo step. + let id = "test.cmds.locks"; + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", GREY))})).unwrap(); + for lock_all in [false, true] { + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + let l = doc.layer_mut(layer).unwrap(); + if lock_all { + l.locks.all = true; + } else { + l.locks.pixels = true; + } + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + let r = s.execute("plugin.run", json!({"id": id})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("is locked")), "lock_all {lock_all}: {r:?}"); + assert_eq!(pixels(&s), before, "lock_all {lock_all}"); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!l.locks.pixels && !l.locks.all, "the refused run left no undo step"); + // Unlocked, the plug-in runs. + s.execute("plugin.run", json!({"id": id})).unwrap(); + assert_ne!(pixels(&s), before); + } + // Lock Transparency: the run changes colour but keeps every pixel's alpha (GREY sets alpha too). + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(layer).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + s.execute("plugin.run", json!({"id": id})).unwrap(); + let after = pixels(&s); + assert_ne!(after, before); + for (a, b) in after.as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "alpha kept"); + if b[3] > 0.0 { + assert!((a[0] - 0.5).abs() < 1e-3, "colour changed: {a:?}"); + } + } + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); +} + #[test] fn hostile_plugins_fail_without_touching_the_document() { for (id, body) in [ diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2c2507226d..a34bda68e9 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -86,7 +86,7 @@ choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); -choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp" } default Png); +choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp", Exr = "exr" } default Png); choice!(ExportLocation { Ask = "ask", SameFolder = "sameFolder" } default Ask); choice!(ExportMetadata { None = "none", Copyright = "copyright", All = "all" } default Copyright); choice!( @@ -108,6 +108,12 @@ choice!(FontPreview { Off = "off", Small = "small", Medium = "medium", Large = " choice!(RawColorSpace { Srgb = "srgb", AdobeRgb = "adobeRgb", ProPhoto = "proPhoto", DisplayP3 = "displayP3" } default AdobeRgb); choice!(RawDepth { Eight = "8", Sixteen = "16" } default Sixteen); choice!(RawSharpen { None = "none", Screen = "screen", Glossy = "glossy", Matte = "matte" } default None); +choice!( + /// How a raw file's colour filter array is filled in when it is developed: AHD + /// (Hirakawa–Parks) keeps edges cleanest, Malvar–He–Cutler is sharp and fast, bilinear is the + /// fastest and softest. Photoshop's Camera Raw has no such choice. + RawDemosaic { Ahd = "ahd", Mhc = "mhc", Bilinear = "bilinear" } default Ahd +); impl Unit { /// Document pixels → this unit. `dpi` is the document resolution, `extent` the 100% length @@ -286,6 +292,10 @@ pub struct Interface { /// own one-row title bar (tiling window managers, desktops that draw their own decorations; /// #1271, #1316). Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Show the active document's file name in the title bar and the OS window title. The tabs + /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). + /// The unsaved-changes mark stays either way. + pub show_file_name_in_title: bool, /// Notices (the lower-right cards) hide themselves after /// [`Interface::notification_duration_seconds`] unless the pointer rests on them (#2022). pub notification_auto_hide: bool, @@ -317,6 +327,7 @@ impl Default for Interface { enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, + show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, } @@ -439,6 +450,10 @@ impl Default for HistoryLog { pub struct FileHandling { pub image_previews: Ask, pub lowercase_extension: bool, + /// File › Save a Copy suggests the document's own name rather than " copy" (#8080). + pub do_not_append_copy: bool, + /// Saves run in the background while you keep working (on), or in the foreground under a + /// modal progress dialog that blocks the app until the file is written (off). pub save_in_background: bool, /// Save recovery information (crash recovery autosave). pub autosave: bool, @@ -458,6 +473,17 @@ pub struct FileHandling { pub recent_file_count: u32, /// Most recently opened files, newest first (File › Open Recent). pub recent_files: Vec, + /// A folder whose `.cube`, `.3dl` and `.look` files Color Lookup lists under its built-in + /// looks (native only, #6558). Empty lists none. + pub lut_folder: String, +} + +impl FileHandling { + /// What File › Save a Copy appends to the document's name: " copy", or nothing with Do not + /// append "copy" to filename when saving a copy (#8080). + pub fn copy_suffix(&self) -> &'static str { + if self.do_not_append_copy { "" } else { " copy" } + } } impl Default for FileHandling { @@ -465,6 +491,7 @@ impl Default for FileHandling { Self { image_previews: Ask::Always, lowercase_extension: true, + do_not_append_copy: false, save_in_background: true, autosave: true, autosave_minutes: 10, @@ -476,6 +503,7 @@ impl Default for FileHandling { rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32, recent_file_count: 20, recent_files: Vec::new(), + lut_folder: String::new(), } } } @@ -507,11 +535,14 @@ impl Default for Export { } } +/// [`Performance::memory_usage_mb`]'s default. +pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Performance { /// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the - /// oldest states are dropped beyond it). + /// oldest states are dropped beyond it), and the result of one transform. pub memory_usage_mb: u32, /// Undo steps kept per document (History panel states). pub history_states: u32, @@ -525,6 +556,11 @@ pub struct Performance { pub rendering_mode: Option, /// Graphics backend (applies at next launch; see [`GpuBackend`]). pub gpu_backend: GpuBackend, + /// Which graphics adapter the window and canvas use on a machine with several (applies at + /// next launch): part of the adapter's name, matched case-insensitively ("nvidia", "arc"). Empty + /// is automatic. `PHOTOCRAFT_GPU_ADAPTER` overrides it for one launch. If nothing matches or the + /// match can't present to the window, startup falls back to automatic and says so. + pub gpu_adapter: String, /// Live previews of large documents (adjustment and filter dialogs, an adjustment layer's /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. @@ -561,15 +597,17 @@ impl Performance { } impl Default for Performance { + /// What a new install starts with, [`DEFAULT_MEMORY_USAGE_MB`] of Memory Usage among it. fn default() -> Self { Self { - memory_usage_mb: 8192, + memory_usage_mb: DEFAULT_MEMORY_USAGE_MB, history_states: 50, cache_levels: 4, cache_tile_size: 8192, use_gpu: true, rendering_mode: None, gpu_backend: GpuBackend::Auto, + gpu_adapter: String::new(), low_resolution_previews: true, low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, @@ -858,6 +896,19 @@ pub struct RawDefaults { /// Photoshop does; off develops it with the defaults straight away. Automation opens never /// show the dialog. pub open_in_camera_raw: bool, + /// The demosaic method raws are developed with; the open-time Camera Raw dialog starts from it. + pub demosaic: RawDemosaic, +} + +impl RawDemosaic { + /// The developer's method. + pub fn method(self) -> photocraft_io::raw::Demosaic { + match self { + RawDemosaic::Ahd => photocraft_io::raw::Demosaic::Ahd, + RawDemosaic::Mhc => photocraft_io::raw::Demosaic::Mhc, + RawDemosaic::Bilinear => photocraft_io::raw::Demosaic::Bilinear, + } + } } impl Default for RawDefaults { @@ -870,6 +921,7 @@ impl Default for RawDefaults { open_as_smart_object: false, apply_auto_tone: false, open_in_camera_raw: true, + demosaic: RawDemosaic::Ahd, } } } @@ -920,6 +972,37 @@ pub struct ToolbarCustomization { pub order: Vec, } +/// Layer Mask Display Options (double-click the layer mask's row in the Channels panel, or Mask +/// Options… on its Layers thumbnail): the colour and opacity of the layer mask rubylith (`\`, +/// ⇧⌥-click the thumbnail). As in Photoshop, one setting for every mask, kept across restarts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayerMaskOverlay { + /// `#rrggbb`. + pub color: String, + /// Percent, 0..=100. + pub opacity: f32, +} + +impl Default for LayerMaskOverlay { + /// Photoshop's: red at 50 %. + fn default() -> Self { + Self { color: "#ff0000".into(), opacity: 50.0 } + } +} + +impl LayerMaskOverlay { + /// The colour as 0..1 RGB; red if the stored value isn't a colour. + pub fn rgb(&self) -> [f32; 3] { + parse_hex(&self.color).unwrap_or([255, 0, 0]).map(|v| f32::from(v) / 255.0) + } + + /// The opacity as a fraction (0..1); 50 % if the stored value isn't a number. + pub fn alpha(&self) -> f32 { + if self.opacity.is_finite() { self.opacity.clamp(0.0, 100.0) / 100.0 } else { 0.5 } + } +} + /// All preferences. JSON keys are the `edit.preferences.
` ids. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -982,6 +1065,12 @@ pub struct Preferences { /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. pub tool_options: Value, + /// Each painting tool's own brush (the Brush, the Eraser, the retouching tools…; see the + /// shell's `paint_mouse::sync_tool_brush`), saved as the user changes them and restored at + /// launch, as Photoshop keeps every tool's brush between sessions (#6212): `{"owner": tool, + /// "brushes": {tool: brush}}`, where `owner` is the tool whose brush `last_brush` holds and + /// the brushes are shaped by `brush_cmds::remembered_settings`. JSON owned by the shell. + pub tool_brushes: Value, /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, @@ -991,6 +1080,8 @@ pub struct Preferences { pub view_extras: Value, /// File › Scripts › Script Events Manager: event → script bindings. pub script_events: crate::automate_cmds::ScriptEvents, + /// Layer Mask Display Options: the layer mask rubylith's colour and opacity. + pub layer_mask_overlay: LayerMaskOverlay, } /// Preferences dialog sections in Photoshop's order: (id, title). @@ -1023,11 +1114,8 @@ pub const SECTIONS: [(&str, &str); 18] = [ /// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", - "general.animatedZoom", - "general.zoomResizesWindows", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", - "fileHandling.saveInBackground", "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", @@ -1038,12 +1126,9 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", "type.textEngine", - "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", - "enhancedControls.touchGestures", "rawDefaults.colorSpace", "rawDefaults.bitDepth", - "rawDefaults.resolution", "rawDefaults.sharpenFor", "rawDefaults.openAsSmartObject", "rawDefaults.applyAutoTone", @@ -1060,7 +1145,16 @@ pub fn is_hidden(path: &str) -> bool { /// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They /// still load, save and round-trip on every platform. -pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"]; +pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; + +/// Whether a graphics adapter called `name` is the one `performance.gpuAdapter` (`wanted`) asks +/// for: every whitespace-separated word of `wanted` appears in the name, ignoring case ("nvidia", +/// "arc a370m"). An empty `wanted` matches nothing. Startup and Preferences share this. +pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool { + let name = name.to_lowercase(); + let mut words = wanted.split_whitespace().peekable(); + words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) +} /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { @@ -1095,6 +1189,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "rawDefaults.colorSpace" => RawColorSpace::NAMES, "rawDefaults.bitDepth" => RawDepth::NAMES, "rawDefaults.sharpenFor" => RawSharpen::NAMES, + "rawDefaults.demosaic" => RawDemosaic::NAMES, "performance.gpuBackend" => GpuBackend::NAMES, "performance.linuxDisplayServer" => LinuxDisplayServer::NAMES, "performance.renderingMode" => RenderingMode::NAMES, @@ -1122,6 +1217,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "unitsAndRulers.printResolution" | "unitsAndRulers.screenResolution" | "rawDefaults.resolution" => (1.0, 10_000.0), "guidesGridAndSlices.gridlineEvery" => (0.001, 10_000.0), "guidesGridAndSlices.subdivisions" => (1.0, 100.0), + "layerMaskOverlay.opacity" => (0.0, 100.0), "type.recentFonts" => (0.0, 50.0), "integrations.controlPort" => (0.0, 65535.0), _ => return None, @@ -1133,6 +1229,9 @@ pub fn is_color(path: &str) -> bool { if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") { return false; } + if path == "layerMaskOverlay.color" { + return true; + } let leaf = path.rsplit('.').next().unwrap_or(path); matches!( leaf, @@ -1462,7 +1561,15 @@ pub fn normalize_shortcut(s: &str) -> Option { Some(s) } +/// Key names spelled with more than one capital (egui's `Key::name`): Title case would turn +/// `PageUp` into `Pageup`, which no key matches, so a binding saved that way never fired (#6827). +const MULTI_WORD_KEYS: [&str; 8] = + ["PageUp", "PageDown", "OpenBracket", "CloseBracket", "OpenCurlyBracket", "CloseCurlyBracket", "BrowserBack", "IntlBackslash"]; + fn capitalize(s: &str) -> String { + if let Some(k) = MULTI_WORD_KEYS.iter().find(|k| k.eq_ignore_ascii_case(s)) { + return (*k).to_string(); + } let mut c = s.chars(); match c.next() { Some(f) if f.is_ascii_alphabetic() => { @@ -1494,7 +1601,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ /// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key /// (the one its menu shows) and answers these as well, as in Photoshop's default set: the /// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace -/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In. +/// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside +/// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus). pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ ("edit.cut", "F2"), ("edit.copy", "F3"), @@ -1502,6 +1610,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ ("edit.fill", "Shift+Backspace"), ("select.inverse", "Shift+F7"), ("view.zoomIn", "Cmd++"), + #[cfg(not(target_os = "macos"))] + ("file.close", "Cmd+F4"), ]; /// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]). @@ -1605,6 +1715,100 @@ impl PrefsStore { } } +/// A saved preference [`Session::load_prefs_json`] couldn't read (a wrong type, an unknown +/// choice) and left at its default. +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidPref { + /// JSON keys from the top of the preferences file (`["fileHandling", "recentFileCount"]`), + /// kept apart because keys such as command ids contain dots. + pub keys: Vec, + /// The value the file held there. + pub value: Value, +} + +impl InvalidPref { + /// The dotted path, as `prefs.get` and `prefs.set` take it. + pub fn path(&self) -> String { + self.keys.join(".") + } +} + +/// How deep [`read_lenient`] looks for the readable parts of an unreadable value. +const LENIENT_DEPTH: usize = 32; + +/// Read `v` as a `T`. When that fails, start from `T::default()` and lay the file's values over +/// it one at a time, keeping each one the whole still reads with and descending into objects +/// that don't, so one bad value resets only itself. The values left out are added to +/// `invalid`, their keys after `prefix`. +fn read_lenient(v: Value, prefix: &[&str], invalid: &mut Vec) -> T +where + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + if let Ok(t) = T::deserialize(&v) { + return t; + } + let prefix: Vec = prefix.iter().map(|k| (*k).to_string()).collect(); + let mut acc = serde_json::to_value(T::default()).unwrap_or(Value::Null); + let fits = |root: &Value| T::deserialize(root).is_ok(); + match v { + Value::Object(file) if acc.is_object() && fits(&acc) => { + lay_over(&mut acc, &mut Vec::new(), file, &fits, &prefix, invalid); + T::deserialize(&acc).unwrap_or_default() + } + value => { + invalid.push(InvalidPref { keys: prefix, value }); + T::default() + } + } +} + +/// [`read_lenient`]'s walk: lay `file` over the object at `at` inside `root`, key by key, so that +/// `root` always reads (`fits`). +fn lay_over( + root: &mut Value, + at: &mut Vec, + file: Map, + fits: &dyn Fn(&Value) -> bool, + prefix: &[String], + invalid: &mut Vec, +) { + for (k, fv) in file { + let Some(obj) = object_at(root, at) else { return }; + let old = obj.insert(k.clone(), fv.clone()); + if fits(root) { + continue; + } + // Back to the default. An object keeps its parts that read, and so does a new map entry + // (a shortcut set, a workspace) when it reads from an empty object. + let Some(obj) = object_at(root, at) else { return }; + let mut descend = fv.is_object() && at.len() < LENIENT_DEPTH && matches!(old, None | Some(Value::Object(_))); + match old { + Some(o) => obj.insert(k.clone(), o), + None if descend => obj.insert(k.clone(), Value::Object(Map::new())), + None => obj.remove(&k), + }; + at.push(k); + if descend && !fits(root) { + descend = false; + if let Some((key, parent)) = at.split_last() + && let Some(obj) = object_at(root, parent) + { + obj.remove(key); + } + } + match fv { + Value::Object(m) if descend => lay_over(root, at, m, fits, prefix, invalid), + value => invalid.push(InvalidPref { keys: prefix.iter().chain(at.iter()).cloned().collect(), value }), + } + at.pop(); + } +} + +/// The JSON object at `path` inside `root`. +fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map> { + path.iter().try_fold(root, |cur, k| cur.get_mut(k.as_str()))?.as_object_mut() +} + impl Session { /// The current preferences. pub fn prefs(&self) -> &Preferences { @@ -1619,7 +1823,7 @@ impl Session { } /// Push engine-relevant preferences into live state: every document's history limit, the - /// layer-effect cache budget and the text engine's missing-glyph protection. + /// 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(); @@ -1630,6 +1834,7 @@ impl Session { st.trim_history(); } photocraft_compose::set_effect_cache_budget(budget << 20); + crate::transform_cmds::set_budget(u64::from(self.prefs().performance.memory_usage_mb) << 20); // Without waiting for the system font scan: this runs on the UI thread at launch. photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); crate::plugin_cmds::sync_prefs(self); @@ -1653,8 +1858,14 @@ impl Session { /// Restore preferences saved by [`Session::prefs_to_json`]. Missing keys keep their /// defaults and unknown keys are ignored, so files from older and newer versions load. - pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result<(), String> { + /// A value of the wrong type (a hand-edited `"recentFileCount": "ten"`) keeps its default + /// and everything else still loads (#6611); the returned list names those values. Only + /// text that isn't a JSON object fails as a whole. + pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result, String> { let mut v: Value = serde_json::from_str(s).map_err(|e| format!("preferences: {e}"))?; + if !v.is_object() { + return Err("preferences: expected a JSON object".into()); + } // Saved preferences before appearance modes had one concrete theme. Preserve its // appearance instead of silently switching established users to Auto. if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) @@ -1675,9 +1886,10 @@ impl Session { } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); - let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?; + let mut invalid = Vec::new(); + let prefs: Preferences = read_lenient(v, &[], &mut invalid); if let Some(c) = color { - self.color.settings = serde_json::from_value(c).unwrap_or_default(); + self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); } self.prefs.edit(|p| *p = prefs); @@ -1685,7 +1897,7 @@ impl Session { self.load_presets_json(v); } self.apply_prefs(); - Ok(()) + Ok(invalid) } /// Full JSON view used by `prefs.get`: preferences plus `colorSettings`. diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 5314d1f197..6f90daf9d2 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -343,6 +343,11 @@ fn shortcuts_normalise_and_detect_conflicts() { assert_eq!(normalize_shortcut("Cmd++").as_deref(), Some("Cmd++")); assert_eq!(normalize_shortcut("Cmd+Shift"), None); assert_eq!(normalize_shortcut("Cmd+A+B"), None); + // #6827: multi-word key names keep egui's spelling (Title case made `Pageup`, matching no key). + assert_eq!(normalize_shortcut("shift+pageup").as_deref(), Some("Shift+PageUp")); + assert_eq!(normalize_shortcut("Ctrl+Alt+PAGEDOWN").as_deref(), Some("Ctrl+Alt+PageDown")); + assert_eq!(normalize_shortcut("Cmd+openbracket").as_deref(), Some("Cmd+OpenBracket")); + assert_eq!(normalize_shortcut("home").as_deref(), Some("Home")); let c = conflicts([("a", "Cmd+J"), ("b", "cmd+j"), ("c", "Cmd+K")]); assert_eq!(c, vec![("Cmd+J".to_string(), vec!["a".to_string(), "b".to_string()])]); } @@ -493,6 +498,32 @@ fn right_click_with_painting_tools_pref() { assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker); } +#[test] +fn gpu_adapter_round_trips_and_defaults_to_automatic() { + let mut s = session(); + assert_eq!(s.prefs().performance.gpu_adapter, ""); + s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": "NVIDIA"})).unwrap(); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + assert!(s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": 3})).is_err()); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.prefs().performance.gpu_adapter, "NVIDIA"); + let mut u = Session::new(); + u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap(); + assert_eq!(u.prefs().performance.gpu_adapter, ""); +} + +#[test] +fn gpu_adapter_names_match_by_word_ignoring_case() { + let arc = "Intel(R) Arc(tm) A370M Graphics (DG2)"; + assert!(gpu_adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(gpu_adapter_matches(arc, " ARC A370M ")); + assert!(!gpu_adapter_matches(arc, "arc a380")); + assert!(!gpu_adapter_matches(arc, "")); + assert!(!gpu_adapter_matches(arc, " ")); +} + #[test] fn gpu_backend_round_trips_and_validates() { let mut s = session(); @@ -553,7 +584,9 @@ fn notification_autohide_preferences() { fn linux_only_preferences_show_only_on_linux() { assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux")); assert!(!is_hidden("performance.gpuBackend")); - assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one"); + assert!(LINUX_ONLY.contains(&"performance.gpuAdapter")); + let free_text = ["performance.gpuAdapter"]; + assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one"); } #[test] @@ -915,3 +948,50 @@ fn a_stranded_backup_is_named_in_the_error() { assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_wrongly_typed_value_resets_only_that_field() { + // #6611: one bad value used to reset every preference. + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": {"beepWhenDone": true}, "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 20}, "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}}"#, + ) + .unwrap(); + assert!(s.prefs().general.beep_when_done); + assert_eq!(s.prefs().file_handling.autosave_minutes, 5, "a sibling in the same section survives"); + assert_eq!(s.prefs().file_handling.recent_file_count, 20, "the bad value takes the default"); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.prefs().shortcuts.get("edit.fill").map(String::as_str), Some("Cmd+Shift+F")); + assert!(!s.prefs().shortcuts.contains_key("file.open")); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["fileHandling.recentFileCount", "shortcuts.file.open"]); + assert_eq!(invalid[0].value, json!("ten")); + assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]); +} + +#[test] +fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": "loud", "performance": {"historyStates": 20}, + "colorSettings": {"workingRgb": "display-p3", "askOnMismatch": "yes"}}"#, + ) + .unwrap(); + assert_eq!(s.prefs().general, General::default()); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.color.settings.working_rgb, "display-p3", "the rest of the colour settings load"); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["general", "colorSettings.askOnMismatch"]); + // A valid file reports nothing; one that isn't an object still fails as a whole. + assert!(s.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + for bad in ["[1, 2]", "42", "\"prefs\"", "null", "{", ""] { + assert!(s.load_prefs_json(bad).is_err(), "{bad}"); + } + // Deep nesting and odd values don't panic. + let deep = format!("{{\"general\": {}1{}}}", "{\"a\":".repeat(100), "}".repeat(100)); + assert!(s.load_prefs_json(&deep).is_ok()); + assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); +} diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8da6cee8a0..14273c2bd9 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -27,12 +27,21 @@ fn always(_: &Session) -> std::result::Result<(), String> { /// Largest accepted file (Photoshop's biggest bundled sets are well under this). pub const MAX_FILE_BYTES: u64 = 512 << 20; +/// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`]. +/// Tied to the decoder's sample budget (1 GiB, 512 MiB on 32-bit targets) so it never refuses +/// what this accepts. +pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { + file_bytes_within(p, cmd, what, MAX_FILE_BYTES) +} + +/// [`file_bytes`] with a size cap of `max` bytes. +fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec, String)> { if let Some(d) = p.get("data") { let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; - if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 { + if s.len() as u64 > max / 3 * 4 + 4 { return Err(bad(cmd, "`data` is too large")); } let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; @@ -41,7 +50,7 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); - if len > MAX_FILE_BYTES { + if len > max { return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; @@ -49,13 +58,32 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S Ok((bytes, stem)) } -/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`… -fn unique(name: &str, taken: &[String]) -> String { - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(name) { - return name.to_string(); +/// The preset names in use, for naming new presets. +struct UniqueNames { + /// Every name in use, ASCII-lowercased. + taken: std::collections::HashSet, + /// The next number to try for each repeated name (lowercased). + next: std::collections::HashMap, +} + +impl UniqueNames { + fn new<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() } + } + + /// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.taken.insert(key.clone()) { + return name.to_string(); + } + // Resume numbering per name, so a file of identical names stays linear. + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.taken.contains(&format!("{key} {i}"))).unwrap_or(from); + self.taken.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") } - (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) } fn import_abr(s: &mut Session, p: &Value) -> Result { @@ -64,7 +92,8 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { // A background job when started with `Session::start` (#210): reading and decoding the // file run on a worker, cancellable before each brush; the library changes on apply. let params = p.clone(); - let params_apply = p.clone(); + // Only the keys the apply step reads: `data` can be a ~1.3 GiB base64 string. + let params_apply = json!({ "replace": p.get("replace"), "select": p.get("select") }); crate::jobs::run( s, label, @@ -72,7 +101,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { move |ctx| { ctx.progress(0.0, "Importing brushes"); let p = ¶ms; - let (bytes, stem) = file_bytes(p, cmd, ".abr file")?; + let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let group = match p.get("group") { Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), None if !stem.is_empty() => stem, @@ -93,11 +122,10 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { s.tools.presets.retain(|x| x.builtin || x.group != group); } - let mut taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); + let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())); let mut names = Vec::with_capacity(imp.presets.len()); for mut preset in imp.presets { - preset.name = unique(&preset.name, &taken); - taken.push(preset.name.clone()); + preset.name = taken.unique(&preset.name); names.push(preset.name.clone()); s.tools.presets.push(preset); } @@ -180,8 +208,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let doc = crate::file_cmds::import(decode_name, &bytes)?; let (v, w, h) = image_paint(&doc, cmd)?; let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?; - let taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); - let name = unique(&name, &taken); + let name = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())).unique(&name); s.tools.presets.push(photocraft_paint::BrushPreset { name: name.clone(), brush: brush.clone(), @@ -526,10 +553,78 @@ mod tests { assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); } + /// A version 1 brush with a computed tip and no name is named after its tip ("9 px Round"). The + /// import used to number each repeated name by searching the preset names once for every number + /// it tried, so importing 10,000 such brushes took minutes. The test fails when the import takes + /// more than 20 seconds. + #[test] + fn repeated_brush_names_are_numbered_in_linear_time() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "9 PX ROUND 3"})).unwrap(); + let round = LegacyBrush { + name: String::new(), + spacing: 25, + anti_alias: true, + tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 }, + }; + let data = photocraft_paint::tile::b64_encode(&write_v12(1, &vec![round; 10_000], false).unwrap()); + let r = s.execute("brush.presets.importAbr", json!({"data": data})).unwrap(); + let names: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let _ = tx.send(names); + }); + let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) { + Ok(names) => names, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the brushes did not finish"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"), + }; + // Numbering starts at 2 and skips the number a preset already uses, in any letter case. + assert_eq!(names[..4], ["9 px Round", "9 px Round 2", "9 px Round 4", "9 px Round 5"]); + assert_eq!(names.last(), Some(&format!("9 px Round {}", names.len() + 1))); + assert_eq!(names.iter().collect::>().len(), names.len()); + } + + #[test] + fn repeated_brush_names_take_the_first_free_number() { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "speckle 3"})).unwrap(); + let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }; + let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip.clone()) }; + let file = write_v12(2, &["Speckle", "SPECKLE", "speckle", "Speckle 2", "Speckle", "hard round"].map(brush), true).unwrap(); + let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&file)})).unwrap(); + // Names that differ only in letter case count as the same name. The file's "Speckle 2" comes + // after "SPECKLE" took that name, and "hard round" clashes with the built-in Hard Round. + assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"])); + } + + #[test] + fn abr_files_get_a_larger_size_cap() { + // 1 GiB on 64-bit; 32-bit (wasm) keeps 512 MiB so a big file fails cleanly instead of aborting. + const WANT: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 }; + const _: () = assert!(MAX_ABR_FILE_BYTES == WANT && MAX_ABR_FILE_BYTES >= MAX_FILE_BYTES); + let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cap.abr"); + std::fs::write(&path, [0u8; 10]).unwrap(); + let p = json!({"path": path.to_string_lossy()}); + assert!(file_bytes_within(&p, "t", "f", 10).is_ok()); + assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); + // A sparse file between the two caps: refused as a .grd, which is checked before reading. + let big = dir.join("big.grd"); + std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); + let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); + let data = json!({"data": "A".repeat(16)}); + assert!(file_bytes_within(&data, "t", "f", 12).is_ok()); + assert!(file_bytes_within(&data, "t", "f", 8).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn unique_names() { - let taken = vec!["A".to_string(), "a 2".to_string()]; - assert_eq!(unique("a", &taken), "a 3"); - assert_eq!(unique("B", &taken), "B"); + let mut taken = UniqueNames::new(["A", "a 2"]); + assert_eq!(taken.unique("a"), "a 3"); + assert_eq!(taken.unique("B"), "B"); } } diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..4105ceb1c0 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -441,15 +441,47 @@ fn active_gradient_fill(s: &Session) -> Option { matches!(d.doc.layer(id)?.content, LayerContent::Fill(Fill::Gradient { .. })).then_some(id) } +fn active_shape(s: &Session) -> Option { + let d = s.active()?; + let id = d.active_layer?; + matches!(d.doc.layer(id)?.content, LayerContent::Shape(_)).then_some(id) +} + fn select(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD_SELECT)?; + let apply = p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true); + let shape = active_shape(s).filter(|_| apply); + // A locked shape refuses before anything changes, the Gradient tool's gradient included. + if let (Some(id), Some(d)) = (shape, s.active()) { + let locks = d.doc.effective_locks(id); + if locks.all || locks.pixels { + return Err(bad(CMD_SELECT, "the selected shape layer is locked")); + } + } s.presets.gradient = g.clone(); s.presets_changed(); let mut out = json!({"current": g.to_json()}); + // Clicking a preset with a shape layer selected fills the shape with it (Photoshop, #5568); + // a gradient fill keeps its angle, style, scale and reverse. + if let Some(id) = shape { + let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); + crate::vector_cmds::with_shape(s, id, "Edit Shape", |sh, _| { + let (angle, scale, style, reverse) = match &sh.fill { + Some(Fill::Gradient { angle, scale, style, reverse, .. }) => (*angle, *scale, *style, *reverse), + _ => (90.0, 1.0, photocraft_doc::GradientStyle::Linear, false), + }; + let mut fill = Fill::gradient(stops_new, angle, scale, style, reverse); + if let Fill::Gradient { opacity_stops, .. } = &mut fill { + *opacity_stops = opacity_new; + } + sh.fill = Some(fill); + Ok(()) + })?; + out["layer"] = json!(id.0); + return Ok(out); + } // Clicking a preset with a Gradient Fill layer selected changes that layer (Photoshop). - if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true) - && let Some(id) = active_gradient_fill(s) - { + if apply && let Some(id) = active_gradient_fill(s) { let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); s.edit("Change Gradient Fill", |doc, _| { let mode = doc.mode; @@ -603,8 +635,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { const CMD: &str = "gradient.presets.apply"; let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); - let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let angle = crate::gradient_fill_cmds::angle_param(CMD, p, 90.0)?; + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -674,7 +706,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."## + r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer, or fills a selected shape layer with it)}} → {{current,layer?}}. The Gradient tool paints with it."## )), enabled: always, run: select, @@ -696,7 +728,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 81382b418a..efdb16cfc8 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -91,6 +91,8 @@ pub struct PresetState { pub layer_defaults: BTreeMap, /// Swatches panel library. pub swatches: Vec>, + /// The user contour library of the Layer Style dialog (`layer.layerStyle.contours.*`). + pub contours: Vec, /// Bumped on every preset change (UIs key thumbnail caches on it). pub rev: u64, } @@ -111,6 +113,7 @@ impl Default for PresetState { clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), + contours: Vec::new(), rev: 0, } } @@ -144,6 +147,9 @@ struct Persisted { #[serde(default)] layer_defaults: Option>, swatches: Option>>, + // Read entry by entry: a bad contour must not discard the other presets. + #[serde(default)] + contours: Option, } impl PresetState { @@ -162,6 +168,7 @@ impl PresetState { custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), + contours: Some(crate::contour_lib_cmds::to_json(&self.contours)), }; serde_json::to_value(p).unwrap_or(Value::Null) } @@ -190,6 +197,7 @@ impl Session { custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), + contours: take(&mut m, "contours"), }; let st = &mut self.presets; if let Some(g) = p.gradients { @@ -230,6 +238,9 @@ impl Session { crate::swatch_cmds::cap_library(&mut g); self.presets.swatches = g; } + if let Some(c) = p.contours.filter(Value::is_array) { + self.presets.contours = crate::contour_lib_cmds::from_json(&c); + } self.presets.rev += 1; } @@ -362,6 +373,9 @@ pub(crate) fn edit_groups(groups: &mut Vec>, action: &str, p: let g = req_str(p, "group", cmd)?; let to = req_str(p, "name", cmd)?.to_string(); let i = groups.iter().position(|x| x.name == g).ok_or_else(|| bad(cmd, format!("no group \"{g}\"")))?; + if g != to && groups.iter().any(|x| x.name == to) { + return Err(bad(cmd, format!("a group named \"{to}\" already exists"))); + } groups[i].name = to.clone(); Ok(json!({"group": to})) } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b7c0a3ede2..89ba8a5855 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -317,6 +317,23 @@ fn gradient_new_rename_move_delete_groups() { assert!(s.execute("gradient.presets.edit", json!({"action": "explode"})).is_err()); } +#[test] +fn gradient_rename_group_refuses_a_name_another_group_has() { + let mut s = Session::new(); + s.execute("gradient.presets.edit", json!({"action": "newGroup", "name": "Pastels"})).unwrap(); + let names = |s: &mut Session| -> Vec { + s.execute("gradient.presets.list", json!({})).unwrap()["groups"].as_array().unwrap().iter().map(|g| g["name"].clone()).collect() + }; + let before = names(&mut s); + assert!(before.contains(&json!("Blues"))); + let err = s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Blues"})).unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(names(&mut s), before, "a refused rename leaves the groups unchanged"); + // Renaming a group to its own name is fine. + s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Pastels"})).unwrap(); + assert_eq!(names(&mut s), before); +} + #[test] fn presets_persist_through_preferences_json() { let mut s = Session::new(); @@ -749,6 +766,48 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["rotation"], json!(-90.0)); } +fn shape_fill(s: &Session, id: LayerId) -> Option { + match &s.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + } +} + +/// #5568: clicking a gradient with a shape layer selected fills the shape with it in one undo +/// step; a gradient fill keeps its angle, style, scale and reverse. +#[test] +fn gradient_preset_click_fills_the_selected_shape_layer() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let before = shape_fill(&s, id); + let r = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap(); + assert_eq!(r["layer"], json!(id.0)); + let Some(Fill::Gradient { stops, angle, style, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + // Red 01 starts at #7f0000; a new gradient fill is linear at 90°. + assert_eq!(stops[0].1.to_rgba8(), [0x7f, 0, 0, 0xff]); + assert_eq!((angle, style), (90.0, photocraft_doc::GradientStyle::Linear)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(shape_fill(&s, id), before); + + s.execute("shape.edit", json!({"layer": id.0, "fill": {"gradient": {"angle": 30, "style": "radial", "scale": 80, "reverse": true}}})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap(); + let Some(Fill::Gradient { angle, style, scale, reverse, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + assert_eq!((angle, style, scale, reverse), (30.0, photocraft_doc::GradientStyle::Radial, 0.8, true)); +} + +/// A locked shape refuses the click before anything changes, the Gradient tool's gradient included. +#[test] +fn gradient_preset_click_on_a_locked_shape_changes_nothing() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let (before, gradient) = (shape_fill(&s, id), s.presets.gradient.name.clone()); + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + let err = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + assert_eq!(shape_fill(&s, id), before); + assert_eq!(s.presets.gradient.name, gradient); +} + // ------------------------------------------------------------------ persistence /// #5602: one damaged preset set in the preferences file must not discard the saved New @@ -758,19 +817,62 @@ fn a_damaged_preset_set_keeps_the_other_sets() { let mut s = Session::new(); let settings = json!({"width": 1600, "height": 900, "resolution": 144}); s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap(); + let export = json!({ + "format": "jpg", "quality": 70, "lossless": false, "transparency": false, "scale": 50, "width": 640, "height": 480, + "lockAspect": true, "sizing": "pixels", "metadata": "exif", "profile": "srgb", "embedProfile": false, "tgaBits": "24" + }); + s.execute("file.export.presets.save", json!({"name": "Web JPEG", "settings": export})).unwrap(); let saved = s.presets.to_json(&s); - for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] { + for key in + ["gradients", "gradient", "styles", "shapes", "pattern_groups", "recent_documents", "custom_shapes", "layer_defaults", "swatches", "export_presets"] + { let mut blob = saved.clone(); blob[key] = json!("not a preset set"); let mut next = Session::new(); next.load_presets_json(blob); assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`"); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); + if key != "export_presets" { + assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`"); + } } + let mut blob = saved.clone(); + blob["export_presets"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.export_presets.is_empty()); + assert_eq!(next.presets.documents, s.presets.documents); + let mut blob = saved.clone(); + blob["documents"] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert!(next.presets.documents.is_empty()); + assert_eq!(next.presets.export_presets, s.presets.export_presets); // Null sets keep their defaults, as before. let mut next = Session::new(); - next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()})); + next.load_presets_json( + json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone(), "export_presets": saved["export_presets"].clone()}), + ); assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len()); assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); assert_eq!(next.presets.documents, s.presets.documents); + assert_eq!(next.presets.export_presets, s.presets.export_presets); +} + +// ------------------------------------------------------------------ export presets + +#[test] +fn export_presets_survive_a_presets_json_round_trip() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); + assert_eq!(s.presets.export_presets.len(), 1); + + let saved = s.presets.to_json(&s); + assert_eq!(saved["export_presets"].as_array().map(Vec::len), Some(1)); + + let mut restored = Session::new(); + restored.load_presets_json(saved); + assert_eq!(restored.presets.export_presets.len(), 1); + assert_eq!(restored.presets.export_presets[0].name, "Web"); + assert_eq!(restored.presets.export_presets[0].settings.format, "jpg"); } diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 796a66623e..2746c1576e 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -37,7 +37,7 @@ impl Named for ToolPreset { pub const MAX_PRESETS: usize = 1000; /// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters. -fn checked_name(name: &str, cmd: &str) -> Result { +pub(crate) fn checked_name(name: &str, cmd: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err(bad(cmd, "the preset name is empty")); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..a0395ff520 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -8,6 +8,12 @@ //! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an //! explicit output path. Shared spooler transport files are private and automatically removed. //! +//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process +//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no +//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`; +//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for +//! one job and always takes the IPP route; `file.printers` lists the queues (#2728). +//! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` //! compound paths), following the public PostScript Language Reference and the AI3 format notes. @@ -31,6 +37,192 @@ fn other(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } +// ---------- Spooling ---------- + +/// A rendered page on its way to the printer: what the IPP route and the app's spooler need. +#[derive(Debug, Clone)] +pub struct PrintJob { + pub pdf: Vec, + /// The printer asked for; `None` means the default. + pub printer: Option, + pub copies: u32, + /// The queue entry's name: the document's. + pub title: String, + /// A print server named in the request (`printServer`), else the system's. + pub server: Option, + /// The page size in points, as laid out (landscape already applied). + pub paper: (f64, f64), + pub landscape: bool, +} + +/// A spooler's work: the job in, where it went out. +pub type SpoolFn = std::sync::Arc std::result::Result + Send + Sync>; + +/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG +/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs +/// no other route can (no print server reachable, no printer set up), or every job when +/// `preferred`. +#[derive(Clone)] +pub struct Spooler { + /// Reported as the job's `backend`. + pub name: &'static str, + pub preferred: bool, + /// Returns where the job went ("the system print dialog"). + pub run: SpoolFn, +} + +/// 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 { + NoRoute(String), + Failed(String), +} + +/// Sends the job and says where it went and through what. A named print server takes the IPP +/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the +/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had +/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// error, never a fall-through. +fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { + let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); + if job.server.is_none() { + if let Some(sp) = spooler.filter(|sp| sp.preferred) { + return app(sp); + } + 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); + } + } + match cups_spool(job) { + Ok(m) => Ok((m, "ipp".into())), + Err(SpoolError::NoRoute(why)) => match spooler { + Some(sp) => app(sp), + None => Err(other(why)), + }, + Err(SpoolError::Failed(why)) => Err(other(why)), + } +} + +/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command +/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the +/// browser. +fn system_spooler() -> Option<&'static str> { + match craft_print::backend()? { + craft_print::Backend::WindowsPdf => Some("windows"), + craft_print::Backend::Cups => { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp") + } + } +} + +/// The printer a job goes to: the one asked for when the server knows it, else the default, +/// else the only one; otherwise an error that names what there is. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result { + let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect(); + let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string()); + if let Some(r) = requested { + let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") }; + return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}"))); + } + if printers.is_empty() { + return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into())); + } + if let Some(d) = default.and_then(known) { + return Ok(d); + } + match names.as_slice() { + [only] => Ok((*only).to_string()), + _ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))), + } +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_server(named: Option<&str>) -> std::result::Result { + match named { + Some(server) => { + let ep = photocraft_ipp::Endpoint::parse(server); + if !ep.reachable() { + return Err(SpoolError::Failed(format!("no print server at {ep}"))); + } + Ok(ep) + } + None => photocraft_ipp::Endpoint::detect() + .ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())), + } +} + +/// The IPP route: the job to the system's (or the named) CUPS server. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string()); + let ep = cups_server(job.server.as_deref())?; + let printers = photocraft_ipp::printers(&ep).map_err(failed)?; + let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?; + let is_class = printers.iter().any(|p| p.name == printer && p.is_class); + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into()); + let id = photocraft_ipp::print_job( + &ep, + &photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" }, + ) + .map_err(failed)?; + Ok(format!("job {id} on {printer}")) +} + +/// No IPP route here: a named server is a failure, the system's a missing route (the app's +/// spooler may have one). +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let why = if cfg!(target_arch = "wasm32") { + "printing needs the desktop app" + } else { + "a print server over IPP is not available on Windows yet; print to PDF instead" + }; + match job.server { + Some(_) => Err(SpoolError::Failed(why.into())), + None => Err(SpoolError::NoRoute(why.into())), + } +} + +/// `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 { + 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})); + } + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] + { + let ep = match cups_server(named) { + Ok(ep) => ep, + Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => { + return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why})); + } + }; + let list = photocraft_ipp::printers(&ep).map_err(other)?; + let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printers: Vec = list + .iter() + .map(|p| { + json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class}) + }) + .collect(); + Ok(json!({"server": ep.to_string(), "default": default, "printers": printers})) + } + #[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))] + { + let _ = named; + Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"})) + } +} + // ---------- PDF ---------- /// Paper sizes in points (portrait). @@ -61,6 +253,27 @@ pub struct Marks { pub corner_crop: bool, pub center_crop: bool, pub registration: bool, + /// Photoshop's Functions › Bleed, in points: corner crop marks sit this far inside the image + /// edge, so the trim cuts through the image and leaves no paper-white sliver. 0 = on the edge. + pub bleed: f64, +} + +/// Gap between the image edge and a crop mark, and the mark's length (pt). +const MARK_GAP: f64 = 6.0; +const MARK_LEN: f64 = 18.0; + +/// Corner crop marks around `(x, y, w, h)` as eight `(from, to)` segments, a horizontal then a +/// vertical one per corner. Each starts `MARK_GAP` outside the image (so it never prints on it) +/// and lies on the trim line, `bleed` inside the edge. The inset is symmetric, so this holds for +/// both bottom-up (PDF) and top-down (Windows) coordinates. +fn corner_crop_segments(bleed: f64, (x, y, w, h): (f64, f64, f64, f64)) -> [((f64, f64), (f64, f64)); 8] { + let mut out = [((0.0, 0.0), (0.0, 0.0)); 8]; + for (i, (cx, cy, dx, dy)) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)].into_iter().enumerate() { + let (tx, ty) = (cx - dx * bleed, cy - dy * bleed); + out[2 * i] = ((cx + dx * MARK_GAP, ty), (cx + dx * (MARK_GAP + MARK_LEN), ty)); + out[2 * i + 1] = ((tx, cy + dy * MARK_GAP), (tx, cy + dy * (MARK_GAP + MARK_LEN))); + } + out } /// One page: paper (pt), image box (pt, origin bottom-left), marks and texts. @@ -76,16 +289,51 @@ pub struct PrintPage { pub emulsion_down: bool, } +// ISO 32000-1, Annex D.2: the standard Helvetica font below uses WinAnsiEncoding, +// not UTF-8. Escape non-ASCII bytes as three octal digits so the content stays ASCII +// and a following digit cannot become part of the escape. Other scripts still need +// an embedded font; retain the existing '?' fallback for unsupported characters. fn pdf_string(s: &str) -> String { let mut o = String::from("("); for c in s.chars() { - match c { - '(' | ')' | '\\' => { + let byte = match c { + '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => c as u8, + '€' => 0x80, + '‚' => 0x82, + 'ƒ' => 0x83, + '„' => 0x84, + '…' => 0x85, + '†' => 0x86, + '‡' => 0x87, + 'ˆ' => 0x88, + '‰' => 0x89, + 'Š' => 0x8a, + '‹' => 0x8b, + 'Œ' => 0x8c, + 'Ž' => 0x8e, + '‘' => 0x91, + '’' => 0x92, + '“' => 0x93, + '”' => 0x94, + '•' => 0x95, + '–' => 0x96, + '—' => 0x97, + '˜' => 0x98, + '™' => 0x99, + 'š' => 0x9a, + '›' => 0x9b, + 'œ' => 0x9c, + 'ž' => 0x9e, + 'Ÿ' => 0x9f, + _ => b'?', + }; + match byte { + b'(' | b')' | b'\\' => { o.push('\\'); - o.push(c); + o.push(char::from(byte)); } - c if c.is_ascii() && !c.is_ascii_control() => o.push(c), - _ => o.push('?'), + 0x80..=0xff => o.push_str(&format!("\\{byte:03o}")), + _ => o.push(char::from(byte)), } } o.push(')'); @@ -102,12 +350,11 @@ fn deflate(data: &[u8]) -> Vec { /// Vector marks around `(x, y, w, h)`. fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { let mut s = String::from("q 0 G 0.3 w\n"); - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); if m.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(m.bleed, (x, y, w, h)) { + line(&mut s, x0, y0, x1, y1); } } if m.center_crop { @@ -284,6 +531,28 @@ fn paper(p: &Value, cmd: &str) -> Result<(f64, f64)> { Ok(if p.get("orientation").and_then(Value::as_str) == Some("landscape") { (h.max(w), w.min(h)) } else { (w, h) }) } +/// The driver's printable area (`printable`: [x, y, width, height] pt from the paper's top-left, +/// as `file.print.page` reports it), clipped to the paper, with the layout's bottom-left origin. +fn printable(p: &Value, (pw, ph): (f64, f64), cmd: &str) -> Result> { + let Some(v) = p.get("printable").filter(|v| !v.is_null()) else { return Ok(None) }; + let r: Option> = v.as_array().and_then(|a| a.iter().map(Value::as_f64).collect()); + let Some([x, y, w, h]) = r.as_deref().and_then(|r| <[f64; 4]>::try_from(r).ok()) else { + return Err(bad(cmd, "printable is [x, y, width, height] in points from the paper's top-left")); + }; + // Drivers report their paper a fraction of a point off the named size (842.29 pt for A3's + // 841.89), so clip that much; an area that doesn't fit the paper (another paper or + // orientation) is refused. 2 pt, as `print_windows` allows between driver and layout. + const SLACK: f64 = 2.0; + if !(x >= -SLACK && y >= -SLACK && x + w <= pw + SLACK && y + h <= ph + SLACK) { + return Err(bad(cmd, "the printable area doesn't fit the paper and orientation")); + } + let (x0, y0, x1, y1) = (x.max(0.0), y.max(0.0), (x + w).min(pw), (y + h).min(ph)); + if !(x1 > x0 && y1 > y0) { + return Err(bad(cmd, "the printable area is empty")); + } + Ok(Some((x0, ph - y1, x1 - x0, y1 - y0))) +} + /// Where the image lands on the paper. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PrintLayout { @@ -298,34 +567,95 @@ pub struct PrintLayout { pub emulsion_down: bool, } -/// The print layout for `doc` with Print parameters `p` (used by the dialog's preview too). +/// Symmetric safety bound for manual Top/Left offsets, shared with the dialog (inches). +pub const MAX_POSITION_INCHES: f64 = 10000.0; + +/// Resolves the shared preview/PDF layout without changing `doc`. +/// +/// Paper and image bounds use points (72 per inch), with the image origin at bottom-left. +/// Manual `top`/`left` are inches from the paper's top-left; negative values place artwork +/// off-page. Center ignores these offsets. Scale to Fit preserves the aspect ratio and uses +/// the available paper area after the existing mark margins, overriding percentage scale. +/// Emulsion Down is recorded for rendering; the returned rectangle is not yet mirrored. +/// +/// Returns an error for malformed manual offsets or offsets beyond [`MAX_POSITION_INCHES`], +/// nonfinite geometry, geometry magnitudes above 1e9, or nonpositive image dimensions. pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { let (pw, ph) = paper(p, cmd)?; let dpi = f64::from(doc.resolution_dpi.max(1.0)); let (iw, ih) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); - let marks = Marks { + // Bleed in inches, like Photoshop's Position fields; the marks need it in points. + let bleed = match p.get("bleed") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|b| b.is_finite() && *b >= 0.0).ok_or_else(|| bad(cmd, "bleed is a width in inches, 0 or more"))? * 72.0, + }; + let mut marks = Marks { corner_crop: p.get("cornerCropMarks").and_then(Value::as_bool).unwrap_or(false), center_crop: p.get("centerCropMarks").and_then(Value::as_bool).unwrap_or(false), registration: p.get("registrationMarks").and_then(Value::as_bool).unwrap_or(false), + bleed, }; - let margin = if marks.corner_crop || marks.center_crop || marks.registration { 36.0 } else { 18.0 }; + let has_marks = marks.corner_crop || marks.center_crop || marks.registration; + // Scale to Fit Media fits the driver's printable area when it is known (the whole sheet for + // borderless paper); marks keep their inset inside it. Without one (CUPS, PDF), the paper + // minus a fixed margin. + let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); - let scale = if fit { - ((pw - 2.0 * margin) / iw).min((ph - 2.0 * margin) / ih) - } else { - p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 + // Only a fitted print reads the area, so a stale one (a replayed action, another paper) can't + // fail a print at a manual scale. + let area = if fit { printable(p, (pw, ph), cmd)? } else { None }; + let fit_box = match area { + Some((x, y, w, h)) => { + // Emulsion Down mirrors the page as it prints: fit the mirror of the printable area, + // so the printed image lands inside the real one. + let x = if emulsion_down { pw - x - w } else { x }; + let inset = if has_marks { 36.0 } else { 0.0 }; + if !(w > 2.0 * inset && h > 2.0 * inset) { + return Err(bad(cmd, "the printable area is too small for crop and registration marks")); + } + (x + inset, y + inset, w - 2.0 * inset, h - 2.0 * inset) + } + None => { + let margin = if has_marks { 36.0 } else { 18.0 }; + (margin, margin, pw - 2.0 * margin, ph - 2.0 * margin) + } }; + let scale = if fit { (fit_box.2 / iw).min(fit_box.3 / ih) } else { p.get("scale").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(100.0) / 100.0 }; let (w, h) = (iw * scale, ih * scale); + // The trim box must keep some area, or the marks of opposite corners would cross. + if 2.0 * marks.bleed >= w.min(h) { + return Err(bad(cmd, format!("bleed {:.3} in leaves no image inside the trim ({:.2} × {:.2} in printed)", marks.bleed / 72.0, w / 72.0, h / 72.0))); + } + // Bleed only moves crop marks; without them it has nothing to change. + if !marks.corner_crop { + marks.bleed = 0.0; + } let center = p.get("center").and_then(Value::as_bool).unwrap_or(true); - let (x, y) = if center { + let (x, y) = if center && fit { + (fit_box.0 + (fit_box.2 - w) / 2.0, fit_box.1 + (fit_box.3 - h) / 2.0) + } else if center { ((pw - w) / 2.0, (ph - h) / 2.0) } else { // Top / left in inches from the paper's top-left corner (Photoshop's Position fields). - let top = p.get("top").and_then(Value::as_f64).unwrap_or(0.0) * 72.0; - let left = p.get("left").and_then(Value::as_f64).unwrap_or(0.0) * 72.0; + let position = |key: &str| -> Result { + let value = match p.get(key) { + None => 0.0, + Some(v) => v.as_f64().ok_or_else(|| bad(cmd, "print positions must be finite numbers in inches"))?, + }; + if !value.is_finite() || value.abs() > MAX_POSITION_INCHES { + return Err(bad(cmd, "print positions must be within -10000 to 10000 inches")); + } + Ok(value * 72.0) + }; + let top = position("top")?; + let left = position("left")?; (left, ph - top - h) }; - let emulsion_down = p.get("emulsionDown").and_then(Value::as_bool).unwrap_or(false); + // Guard geometry before converting to screen points or writing PDF operators. Even a + // finite JSON scale can overflow when multiplied by document dimensions. + if ![pw, ph, x, y, w, h, scale].into_iter().all(|v| v.is_finite() && v.abs() <= 1.0e9) || w <= 0.0 || h <= 0.0 { + return Err(bad(cmd, "print layout must have finite geometry within 1000000000 points and positive image dimensions")); + } Ok(PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down }) } @@ -343,6 +673,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); + let spooler = s.print_spooler.clone(); if send && !dry { // Session::execute remains synchronous; the desktop's Session::start snapshots here // and runs PDF composition and the potentially slow driver on a worker. @@ -352,7 +683,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { false, move |ctx| { ctx.progress(0.0, "Print"); - render_and_spool(&doc, &worker_params, &command) + render_and_spool(&doc, &worker_params, &command, spooler.as_ref()) }, move |s, result| { remember_print(s, ¶ms); @@ -370,13 +701,55 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }, ); } - let result = render_and_spool(&doc, p, cmd)?; + let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?; remember_print(s, p); crate::automate_cmds::fire_event(s, "print"); Ok(result) } -fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { +/// True when a print may go to a Windows printer through the driver rather than a PDF spooler. +fn native_route(p: &Value, spooler: Option<&Spooler>) -> bool { + // A named print server always takes the IPP route and a preferred app spooler takes every + // job (the route order in `spool_job`), on Windows too: neither may enter the native path, + // which would print on the local default printer instead. + let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); + let output = p.get("output").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let send = p.get("send").and_then(Value::as_bool).unwrap_or(!output); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + cfg!(windows) && !skip_native && send && !dry && !output +} + +/// `p` with the driver's printable area, read from the printer itself, so Print One Copy, the +/// CLI, control and replayed actions fit the same area as the Print dialog does (#7782). A +/// native print never fits an area the caller passed: without the driver's, it has none. +fn with_driver_printable(p: &Value) -> Value { + let mut out = p.clone(); + if let Some(o) = out.as_object_mut() { + o.remove("printable"); + if let Some(area) = driver_printable(p) { + o.insert("printable".into(), area); + } + } + out +} + +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()?; + // 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()?; + if (pg.paper.0 - pw).abs() > 2.0 || (pg.paper.1 - ph).abs() > 2.0 { + return None; + } + Some(json!([pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3])) +} + +fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { + let fit = p.get("scaleToFit").and_then(Value::as_bool).unwrap_or(false); + let driver = (fit && native_route(p, spooler)).then(|| with_driver_printable(p)); + let p = driver.as_ref().unwrap_or(p); let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; let description = p @@ -394,18 +767,20 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); - if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + let native = native_route(p, spooler); + if native && native_rgb_compatible(&page, handling) { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ - "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, + "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } - if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + if native && printer_settings(p)?.is_some() { return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); + let pdf_len = pdf.len(); let job = craft_print::Job { printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), copies, @@ -425,16 +800,56 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { }; let mut sent = false; let mut spool = Value::Null; + let mut backend = Value::Null; if send && !dry { require_printing_enabled()?; - spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); + let print_job = PrintJob { + printer: job.printer.clone(), + copies, + title: doc.name.clone(), + server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + paper: (pw, ph), + landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"), + pdf, + }; + let (where_, via) = spool_job(spooler, &print_job, &job)?; + spool = json!(where_); + backend = json!(via); sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "bleed": marks.bleed / 72.0, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}), ) } +/// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their +/// `build.rs`) so a Windows image without it still starts. Calling into it when it cannot load +/// would abort the process, so check that it loads first (#5884). +fn require_windows_pdf() -> Result<()> { + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); + if !windows_pdf_loads(&windows_pdf_dll(std::path::Path::new(&root))) { + return Err(other( + "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is missing or cannot load. Save as PDF and print it from another app, or print an RGB image without page text or labels to print directly.", + )); + } + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_pdf_dll(system_root: &std::path::Path) -> std::path::PathBuf { + system_root.join("System32").join("Windows.Data.Pdf.dll") +} + +/// Loads the DLL rather than checking the file exists: one that is present but cannot load (a +/// dependency stripped too) would still abort at the delay-load stub. +#[cfg(target_os = "windows")] +fn windows_pdf_loads(dll: &std::path::Path) -> bool { + dll.to_str().is_some_and(|p| winsafe::HINSTANCE::LoadLibrary(p).is_ok()) +} + /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently /// draws RGB/gray and vector marks only; it must never silently omit requested page content. fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { @@ -444,7 +859,11 @@ fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { && page.label.as_deref().is_none_or(str::is_empty) } -/// Print One Copy repeats these. +/// Where the settings of the last print are kept in Preferences' remembered dialog settings. +pub const REMEMBERED: &str = "file.print"; + +/// Print One Copy repeats these, and Print opens with them. They are kept in the preferences as +/// well, so they survive a relaunch, as Photoshop's do (#7410). fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); if let Some(o) = remembered.as_object_mut() { @@ -452,10 +871,35 @@ fn remember_print(s: &mut Session, p: &Value) { o.remove("dryRun"); // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); + // The driver's printable area belongs to the printer and paper chosen now: the dialog + // reads it again, and a later print to PDF or another printer must not inherit it. + o.remove("printable"); } + let kept = remembered.clone(); + s.prefs.edit(|prefs| { + prefs.dialogs.insert(REMEMBERED.into(), kept); + }); s.file_menu.last_print = Some(remembered); } +/// Drops the `printer` of the settings `q` (and its driver settings) when the print server lists +/// printers but not that one, so the job goes to the default printer. An empty list can't tell. +fn drop_vanished_printer(q: &mut Value, listed: &[&str]) { + let Some(name) = q.get("printer").and_then(Value::as_str).filter(|n| !n.is_empty()) else { return }; + if listed.is_empty() || listed.iter().any(|l| l.eq_ignore_ascii_case(name)) { + return; + } + if let Some(o) = q.as_object_mut() { + o.remove("printer"); + o.remove("printerSettings"); + } +} + +/// The settings of the last print: this session's, else those remembered from an earlier one. +pub fn last_print(s: &Session) -> Option { + s.file_menu.last_print.clone().or_else(|| s.prefs().dialogs.get(REMEMBERED).filter(|v| v.is_object()).cloned()) +} + /// The `printer` parameter, or the system's default printer. fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { @@ -472,6 +916,61 @@ fn printer_settings(p: &Value) -> Result> { } } +/// `dmFields` and `dmOrientation` in DEVMODEW (wingdi.h: after the 32-wchar device name and four +/// header words), and the `dmFields` bit that says `dmOrientation` is set. +const DM_FIELDS_AT: usize = 72; +const DM_ORIENTATION_AT: usize = 76; +const DM_ORIENTATION: u32 = 0x1; +/// `DMORIENT_PORTRAIT` and `DMORIENT_LANDSCAPE`. +const DMORIENT_PORTRAIT: i16 = 1; +const DMORIENT_LANDSCAPE: i16 = 2; + +/// Driver settings turned to portrait or landscape, as Photoshop's Layout buttons turn them: +/// `dmOrientation` set and flagged in `dmFields`, every other byte (the driver's private settings +/// included) kept. The driver validates the result when it lays out the page, as it does for +/// settings from Print Settings…. +pub fn turned_settings(settings: &craft_print::rgb::DevMode, landscape: bool) -> Result { + let mut b = settings.as_bytes().to_vec(); + let short = || other("printer settings are too short to hold an orientation"); + let fields: [u8; 4] = b.get(DM_FIELDS_AT..DM_FIELDS_AT + 4).and_then(|s| s.try_into().ok()).ok_or_else(short)?; + let fields = u32::from_le_bytes(fields) | DM_ORIENTATION; + b.get_mut(DM_FIELDS_AT..DM_FIELDS_AT + 4).ok_or_else(short)?.copy_from_slice(&fields.to_le_bytes()); + let orientation = if landscape { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT }; + b.get_mut(DM_ORIENTATION_AT..DM_ORIENTATION_AT + 2).ok_or_else(short)?.copy_from_slice(&orientation.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(b).map_err(other) +} + +/// Whether a driver page of `driver` points is the layout's `paper` turned the other way round +/// (within the 2 pt the driver and layout may differ by): then turning the driver settings, not +/// choosing another paper, makes them agree. A square paper is never turned. +fn turned_paper(driver: (f64, f64), paper: (f64, f64)) -> bool { + let near = |a: f64, b: f64| (a - b).abs() <= 2.0; + let same = near(driver.0, paper.0) && near(driver.1, paper.1); + !same && near(driver.0, paper.1) && near(driver.1, paper.0) +} + +/// The driver settings and page for printing a layout of `paper` points on `printer`: the given +/// settings (or the printer's own) as they are, or turned to the layout's orientation when the +/// driver has that paper the other way round. A landscape layout so prints landscape with portrait +/// Print Settings, as a script or another app asking for landscape expects. +fn settings_for_layout( + printer: &str, + settings: Option, + paper: (f64, f64), +) -> Result<(Option, craft_print::rgb::Page)> { + let page = 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)?, + }; + let turned = turned_settings(&base, paper.0 > paper.1)?; + let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?; + Ok((Some(turned), page)) +} + /// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down. pub fn page_rgb(page: &PrintPage) -> Result> { let img = &page.image; @@ -506,8 +1005,7 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { /// PDF-only (no font/text layout in the GDI path yet). fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result { let printer = printer_name(p)?; - let settings = printer_settings(p)?; - let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; + let (settings, device_page) = settings_for_layout(&printer, printer_settings(p)?, page.paper)?; if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 { return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing")); } @@ -544,11 +1042,31 @@ fn printers(_: &mut Session, _: &Value) -> Result { } fn printer_page(_: &mut Session, p: &Value) -> Result { + // Checked before the printer, so a bad value is the error whether or not a printer answers. + let landscape = match p.get("orientation").filter(|v| !v.is_null()) { + None => None, + Some(v) => match v.as_str() { + Some("portrait") => Some(false), + Some("landscape") => Some(true), + _ => return Err(bad("file.print.page", "orientation is \"portrait\" or \"landscape\"")), + }, + }; let printer = printer_name(p)?; - let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; - Ok( - 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]}), - ) + let mut settings = printer_settings(p)?; + if let Some(landscape) = landscape { + let base = match settings { + Some(s) => s, + None => 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 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()) { + o.insert("printerSettings".into(), json!(s.to_hex())); + } + Ok(out) } /// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. @@ -573,21 +1091,18 @@ fn require_printing_enabled() -> Result<()> { } } -/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless -/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser. -fn system_printers(_: &mut Session, _: &Value) -> Result { - 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(); - Ok(json!({"printers": printers, "default": default})) -} - fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } fn print_one_copy(s: &mut Session, p: &Value) -> Result { - let mut q = s.file_menu.last_print.clone().unwrap_or_else(|| json!({})); + let mut q = last_print(s).unwrap_or_else(|| json!({})); + // A printer remembered from an earlier session may be gone: then the default takes the job, + // as the Print dialog falls back (#7410). A list that can't be read decides nothing. + if let Ok(listed) = system_printers(s, &q) { + let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + drop_vanished_printer(&mut q, &names); + } if let (Some(o), Some(extra)) = (q.as_object_mut(), p.as_object()) { for (k, v) in extra { o.insert(k.clone(), v.clone()); @@ -602,8 +1117,15 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result { fn package(s: &mut Session, p: &Value) -> Result { let cmd = "file.package"; let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?; + // The copies are relinked by absolute path: a relative one would be resolved against the + // package document's folder, not the working directory `dir` is relative to. + // `folder`, `document` and `links` are built on it with `join`, so it is spelt the same way: + // `/` throughout, not a native prefix with `/` after it. + let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned()); + let dir = crate::file_cmds::forward_slashes(dir); let d = s.active().ok_or(EngineError::NoDocument)?; let doc = (*d.doc).clone(); + let document = d.path.clone(); let name = stem(&d.path.clone().unwrap_or_else(|| doc.name.clone())); // The native format keeps external links; our PSD writer has no `lnkE` (external linked // file) support yet, so a PSD package would embed or flatten them. @@ -612,7 +1134,7 @@ fn package(s: &mut Session, p: &Value) -> Result { Some(f) => return Err(bad(cmd, format!("unknown package format `{f}` (pcraft|psd|psb)"))), None => "pcraft".into(), }; - let folder = join(dir, &crate::file_cmds::sanitize(&name)); + let folder = join(&dir, &crate::file_cmds::sanitize(&name)); let links_dir = join(&folder, "Links"); // Copy every linked file once; relink the copy of the document to the copies. let mut copied: Vec<(String, String)> = Vec::new(); @@ -629,7 +1151,12 @@ fn package(s: &mut Session, p: &Value) -> Result { fname = format!("{}-{}", copied.len(), fname); } let t = join(&links_dir, &fname); - match read_file(path) { + // A path that is not followed is packaged as missing, like a file that is gone. + let Ok(file) = crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)) else { + missing.push(path.clone()); + continue; + }; + match read_file(&file.to_string_lossy()) { Ok(bytes) => { write_file(&t, &bytes)?; copied.push((path.clone(), t.clone())); @@ -750,7 +1277,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"printable":[x,y,w,h] pt? (the driver's printable area from the paper's top-left, as file.print.page reports it; Scale to Fit Media fits it, else the paper minus 18 pt; a native Windows print reads the driver's own instead),"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"bleed":in=0 (Functions › Bleed: corner crop marks this far inside the image edge, on the trim line),"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), @@ -769,7 +1296,7 @@ pub fn specs() -> Vec { label: "Printer Page", menu: &[], shortcut: None, - params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##, + params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##, enabled: |_| Ok(()), journal: false, run: printer_page, diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..43f49101a7 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -1,11 +1,75 @@ use super::*; use photocraft_doc::{Knot, NamedPath, Path, Subpath}; +/// Compares signed-offset layout with actual PDF image operators and whole-page mirroring +/// across scale/fit/orientation cases, while checking the source document stays unchanged. +#[test] +fn negative_print_positions_match_pdf_without_changing_the_document() { + let dir = tmp("negative-position"); + for (scale, fit, landscape, mirrored) in + [(100.0, false, false, false), (175.0, false, true, false), (50.0, true, false, false), (100.0, false, false, true)] + { + let mut s = session("rgb", 8); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let path = format!("{dir}/{scale}-{fit}-{landscape}-{mirrored}.pdf"); + let p = json!({"paper": "a4", "center": false, "left": -0.5, "top": -0.25, + "scale": scale, "scaleToFit": fit, "orientation": if landscape {"landscape"} else {"portrait"}, + "emulsionDown": mirrored, "output": path, "send": false, "dryRun": true}); + let preview = layout(&before, &p, "file.print").unwrap(); + let (x, y, w, h) = preview.rect; + assert_eq!(x, -36.0); + assert!((preview.paper.1 - y - h + 18.0).abs() < 1e-9); + assert!((w / h - 2.0).abs() < 1e-9); + if !fit { + assert!((w - 300.0 * scale / 100.0).abs() < 1e-9); + } else { + assert!((w - (preview.paper.0 - 36.0)).abs() < 1e-9); + } + let result = s.execute("file.print", p).unwrap(); + assert_eq!(result["sent"], false); + assert_eq!(result["imageRect"], json!([x, y, w, h])); + let pdf = std::fs::read(path).unwrap(); + let content = pdf_content(&pdf); + assert!(content.contains(&format!("q {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q"))); + assert_eq!(content.contains(&format!("q -1 0 0 1 {:.2} 0 cm", preview.paper.0)), mirrored); + let after = s.active().unwrap(); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.size, before.size); + assert_eq!(after.doc.resolution_dpi, before.resolution_dpi); + assert_eq!(photocraft_compose::thumbnail(&before, 32).pixels, photocraft_compose::thumbnail(&after.doc, 32).pixels); + } +} + +/// Checks inclusive signed inch limits, malformed manual offsets and overflow-prone scale +/// or paper inputs at the shared layout boundary, without invoking a printer. +#[test] +fn print_position_and_geometry_bounds_reject_malformed_inputs() { + let s = session("rgb", 8); + let doc = &s.active().unwrap().doc; + for key in ["top", "left"] { + for value in [json!(-10000.0), json!(10000.0), json!(-0.125)] { + let mut p = json!({"center": false}); + p[key] = value; + assert!(layout(doc, &p, "file.print").is_ok()); + } + for value in [json!(1e308), json!(-10001.0), json!("NaN"), Value::Null] { + let mut p = json!({"center": false}); + p[key] = value; + assert!(layout(doc, &p, "file.print").is_err()); + } + } + assert!(layout(doc, &json!({"scale": 1e308}), "file.print").is_err()); + assert!(layout(doc, &json!({"paper": [1e308, 792]}), "file.print").is_err()); +} + fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d.to_string_lossy().into_owned() + // Spelt the way command results spell paths, so an expectation built with `/` matches on + // Windows too. Win32 takes `/` as a separator, so the directory is still usable as one. + d.to_string_lossy().replace('\\', "/") } fn session(mode: &str, depth: u32) -> Session { @@ -205,6 +269,79 @@ fn printer_commands_answer_without_a_document_and_reject_bad_settings() { assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err()); } +/// 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 { + let mut b: Vec = (0..usize::from(size) + usize::from(extra)).map(|i| (i % 251) as u8).collect(); + b[68..70].copy_from_slice(&size.to_le_bytes()); + b[70..72].copy_from_slice(&extra.to_le_bytes()); + b[72..76].copy_from_slice(&0x2u32.to_le_bytes()); // DM_PAPERSIZE + b +} + +/// Photoshop's Layout buttons: driver settings turned to landscape or portrait set `dmOrientation` +/// and its `dmFields` bit, and keep every other byte, the driver's private settings included. +#[test] +fn turned_settings_set_the_orientation_and_keep_the_rest() { + for (size, extra) in [(220u16, 0u16), (220, 48)] { + let before = devmode(size, extra); + let dm = craft_print::rgb::DevMode::from_bytes(before.clone()).unwrap(); + for (landscape, orient) in [(true, 2i16), (false, 1i16)] { + let after = turned_settings(&dm, landscape).unwrap().as_bytes().to_vec(); + assert_eq!(after.len(), before.len()); + assert_eq!(i16::from_le_bytes([after[76], after[77]]), orient, "{size}+{extra} landscape={landscape}"); + assert_eq!(u32::from_le_bytes([after[72], after[73], after[74], after[75]]), 0x3, "DM_ORIENTATION added, DM_PAPERSIZE kept"); + for (i, (a, b)) in after.iter().zip(&before).enumerate() { + if !(72..76).contains(&i) && !(76..78).contains(&i) { + assert_eq!(a, b, "byte {i} changed"); + } + } + } + } +} + +/// The driver's paper is turned only when it is the layout's paper the other way round (within +/// the 2 pt a driver's paper may differ by): not for the same paper, another paper or a square one. +#[test] +fn only_the_same_paper_the_other_way_round_is_turned() { + let (a3, a3_wide) = ((842.29, 1191.14), (1190.55, 841.89)); + assert!(turned_paper(a3, a3_wide), "portrait driver, landscape layout"); + assert!(turned_paper((1191.14, 842.29), (841.89, 1190.55)), "landscape driver, portrait layout"); + assert!(!turned_paper(a3, (841.89, 1190.55)), "same orientation"); + assert!(!turned_paper((595.32, 841.92), a3_wide), "an A4 driver for an A3 layout"); + assert!(!turned_paper((612.0, 612.0), (612.0, 612.0)), "square"); +} + +/// `file.print.page` takes an orientation and refuses any other value before asking a printer, so +/// the error is the same with or without one. +#[test] +fn printer_page_refuses_an_unknown_orientation() { + let mut s = Session::new(); + for bad in [json!("sideways"), json!(2), json!(true)] { + let e = s.execute("file.print.page", json!({"printer": "Anything", "orientation": bad})).unwrap_err().to_string(); + assert!(e.contains("orientation"), "{bad}: {e}"); + } +} + +/// A real driver turns: Windows' "Microsoft Print to PDF" on its default (portrait) settings, for a +/// landscape A4 layout, gets settings turned to landscape and a wide page; a portrait layout keeps +/// the driver's own settings. Needs that printer, so it runs by hand: +/// `cargo test -p photocraft-engine --lib a_portrait_driver -- --ignored`. +#[cfg(windows)] +#[test] +#[ignore] +fn a_portrait_driver_turns_for_a_landscape_layout() { + let printer = "Microsoft Print to PDF"; + let defaults = craft_print::rgb::default_settings(printer).unwrap(); + let a4 = craft_print::rgb::page(printer, Some(&defaults)).unwrap().paper; + let (settings, page) = settings_for_layout(printer, None, (a4.1, a4.0)).unwrap(); + assert!(settings.is_some(), "turned settings come back"); + assert!((page.paper.0 - a4.1).abs() <= 2.0 && (page.paper.1 - a4.0).abs() <= 2.0, "wide: {:?}", page.paper); + let (settings, page) = settings_for_layout(printer, None, a4).unwrap(); + assert!(settings.is_none(), "nothing to turn"); + assert!((page.paper.0 - a4.0).abs() <= 2.0, "{:?}", page.paper); +} + #[test] fn printer_profile_can_be_an_installed_icc_file() { let dir = tmp("icc"); @@ -224,6 +361,48 @@ fn printer_profile_can_be_an_installed_icc_file() { let _ = std::fs::remove_dir_all(&dir); } +/// #7410: the settings of the last print survive a relaunch, as Photoshop's do. They are kept in +/// the preferences (without the output, dry-run and send choices), and a new session with those +/// preferences, before printing anything, repeats them with Print One Copy. +#[test] +fn the_last_print_settings_survive_a_relaunch() { + let mut s = session("rgb", 8); + assert!(last_print(&s).is_none()); + s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50, "intent": "perceptual"})).unwrap(); + let kept = s.prefs().dialogs.get(REMEMBERED).cloned().expect("kept in the preferences"); + assert_eq!((&kept["paper"], &kept["scale"], &kept["intent"]), (&json!("a5"), &json!(50), &json!("perceptual"))); + assert!(kept.get("dryRun").is_none() && kept.get("output").is_none() && kept.get("send").is_none(), "{kept}"); + // A relaunch: a new session with these preferences, nothing printed in it yet. + let mut t = session("rgb", 8); + t.prefs.edit(|p| { + p.dialogs.insert(REMEMBERED.into(), kept.clone()); + }); + assert_eq!(last_print(&t), Some(kept)); + let r = t.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + assert_eq!(r["copies"], 1); + assert_eq!(r["paper"], json!([419.53, 595.28])); + assert_eq!(r["scale"], 50.0); +} + +/// #7410: a printer remembered from an earlier session that the print server no longer lists is +/// dropped before Print One Copy repeats the settings (with its driver settings), so the default +/// printer takes the job; a listed one (in any case) stays, and an empty list can't tell. +#[test] +fn a_vanished_remembered_printer_falls_back_to_the_default() { + let settings = || json!({"printer": "Office", "printerSettings": "00ff", "paper": "a5"}); + let mut q = settings(); + drop_vanished_printer(&mut q, &["Studio", "Lab"]); + assert_eq!(q, json!({"paper": "a5"}), "gone: the default printer takes it"); + for listed in [&["Studio", "office"][..], &[][..]] { + let mut q = settings(); + drop_vanished_printer(&mut q, listed); + assert_eq!(q, settings(), "{listed:?}"); + } + let mut q = json!({"printer": "", "paper": "a5"}); + drop_vanished_printer(&mut q, &["Studio"]); + assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); +} + #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); @@ -254,7 +433,12 @@ fn package_copies_links_and_relinks() { let copied = format!("{dir}/pkg/Print Me/Links/art.png"); assert_eq!(r["links"], json!([copied])); assert!(std::path::Path::new(&copied).is_file()); + // Reported paths are `/`-spelt on every platform, not the native separator up to `dir` and + // `/` after it: a client hands them back to the control channel, which takes only `/`. let docp = r["document"].as_str().unwrap(); + for p in [r["folder"].as_str().unwrap(), docp, r["links"][0].as_str().unwrap()] { + assert!(!p.contains('\\'), "{p}"); + } assert!(docp.ends_with("Print Me/Print Me.pcraft")); let back = photocraft_io::import("Print Me.pcraft", &std::fs::read(docp).unwrap()).unwrap().document; let linked: Vec = back @@ -360,7 +544,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document"); assert_eq!(out["sent"], true); - assert_eq!(out["spooler"], "request id is Office-42"); + assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp"))); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -434,3 +618,545 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above +/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs +/// the test body; the parent starts the child and checks that it passed. +fn in_child(test: &str, envs: &[(&str, &str)]) -> bool { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let mut cmd = std::process::Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT"); + for (k, v) in envs { + cmd.env(k, v); + } + // Bounded: a child stuck in a printer dialog or a driver must not hang the suite. + let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + let out = child.wait_with_output().unwrap(); + assert!( + status.is_some_and(|s| s.success()), + "{}\nstdout: {}\nstderr: {}", + if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" }, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + false +} + +/// A spooler that keeps every job it gets. +fn recording_spooler(preferred: bool, seen: &std::sync::Arc>>) -> Spooler { + let seen = std::sync::Arc::clone(seen); + Spooler { + name: "test", + preferred, + run: std::sync::Arc::new(move |job: &PrintJob| { + seen.lock().unwrap().push(job.clone()); + Ok("queued".into()) + }), + } +} + +#[test] +fn a_preferred_app_spooler_takes_the_job() { + if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) { + return; + } + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = session("rgb", 8); + s.print_spooler = Some(recording_spooler(true, &seen)); + let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued"))); + assert!(r["pdf"].is_null(), "{r}"); + let jobs = seen.lock().unwrap(); + let job = &jobs[0]; + assert!(job.pdf.starts_with(b"%PDF-1.4")); + assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true)); + assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01); + drop(jobs); + // A named print server asks for the IPP route, preferred spooler or not. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}"); + assert_eq!(seen.lock().unwrap().len(), 1); + // "output" keeps the file and does not spool unless asked. + let out = format!("{}/out.pdf", tmp("output")); + let r = s.execute("file.print", json!({"output": out})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str()))); + assert!(std::path::Path::new(&out).exists()); +} + +#[cfg(not(target_os = "windows"))] +mod cups { + use super::*; + use photocraft_ipp::{ + Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP, + }; + use std::io::{BufRead, BufReader, Read, Write}; + use std::sync::mpsc; + + fn printer(name: &str, is_default: bool) -> Printer { + Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false } + } + + #[test] + fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() { + let two = [printer("Office", false), printer("Lab", true)]; + assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into())); + assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into())); + assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into())); + match resolve_printer(None, None, &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"), + _ => panic!("several printers and no default must fail"), + } + match resolve_printer(Some("Nope"), Some("Lab"), &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"), + _ => panic!("an unknown printer must fail"), + } + match resolve_printer(None, None, &[]) { + Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"), + _ => panic!("no printers is no route"), + } + // A printer asked for by name on a server without any is a failure, not a missing route. + match resolve_printer(Some("Office"), None, &[]) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"), + _ => panic!("a named printer never falls through"), + } + // A stale default that the server no longer lists falls through to the only printer. + assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into())); + } + + /// What the fake received per request: op, attributes, document. + type Seen = (u16, Vec, Vec); + + /// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs + /// as id 42. Answers for as long as the test process lives. + fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let mut reader = BufReader::new(stream); + let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if first.is_empty() { + first = line.clone(); + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + continue; // a reachability probe + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap(); + let list = |names: &[&str]| { + if names.is_empty() { + return Request::new(0x0406, 1).end(); + } + let mut r = Request::new(0, 1); + for p in names { + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", p.as_bytes()); + } + r.end() + }; + let reply = match op { + OP_CUPS_GET_PRINTERS => list(printers), + OP_CUPS_GET_CLASSES => list(classes), + OP_CUPS_GET_DEFAULT => match default { + Some(d) => { + let mut r = Request::new(0, 1); + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", d.as_bytes()); + r.end() + } + None => Request::new(0x0406, 1).end(), + }, + OP_PRINT_JOB => { + let mut r = Request::new(0, 1); + r.group(TAG_JOB_GROUP); + r.integer("job-id", 42); + r.end() + } + _ => Request::new(0x0501, 1).end(), + }; + let _ = tx.send((op, attrs, doc)); + let mut out = reader.into_inner(); + let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + let _ = out.write_all(&reply); + let _ = out.flush(); + } + }); + (server, rx) + } + + #[test] + fn a_named_server_is_printed_to_over_ipp() { + if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) { + return; + } + let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab")); + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab"))); + assert!(r["pdf"].is_null()); + let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request"); + let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}")); + assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text()); + assert_eq!( + (attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()), + (Some(3), "Print Me.psd", "application/pdf") + ); + assert!(job.2.starts_with(b"%PDF-1.4")); + // The requested printer, in any case; an unknown one names the choices. + let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap(); + assert_eq!(r["spooler"], "job 42 on Office"); + let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job"); + assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office"))); + let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string(); + assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"); + // Printers can be listed. + let r = s.execute("file.printers", json!({"printServer": server})).unwrap(); + let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab"))); + assert_eq!( + (r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()), + (Some(true), Some(true), Some(false)) + ); + } + + #[test] + fn without_a_printer_the_app_spooler_takes_over() { + if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) { + return; + } + let (server, _rx) = fake_cups(&[], &[], None); + let mut s = session("gray", 8); + let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string(); + assert!(e.contains("no printer is set up"), "{e}"); + // Asking for a printer by name never falls through to another route. + let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string(); + assert!(e.contains("`Office`"), "{e}"); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + let r = s.execute("file.print", json!({"printServer": server})).unwrap(); + assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued"))); + assert_eq!(seen.lock().unwrap().len(), 1); + // An unreachable named server is a failure, not a missing route. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at"), "{e}"); + let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap(); + assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server")); + } + + /// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP + /// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler. + /// Runs in a child process, since the path is process-wide. + #[test] + fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP"; + if std::env::var_os(CHILD).is_some() { + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"copies": 2})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office"))); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + // The IPP route first, then the app's spooler only when there is no server. + let r = s.execute("file.print", json!({})).unwrap(); + assert_eq!(r["backend"], "ipp"); + assert!(seen.lock().unwrap().is_empty()); + // (CUPS_SERVER is read per job; an unreachable one is no route.) + let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(r.contains("no print server at"), "{r}"); + println!("child ok"); + return; + } + let (server, rx) = fake_cups(&["Office"], &[], Some("Office")); + let no_lp = tempfile::tempdir().unwrap(); + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"]) + .env(CHILD, "1") + .env("PATH", no_lp.path()) + .env("CUPS_SERVER", &server) + .env_remove("PHOTOCRAFT_NO_PRINT") + .output() + .unwrap(); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}"); + let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count(); + assert_eq!(jobs, 2, "both prints reached the fake CUPS"); + } +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_pdf_component_is_detected_by_loading_its_dll() { + // #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of + // aborting at the delay-load stub. + let dir = tempfile::tempdir().unwrap(); + let dll = windows_pdf_dll(dir.path()); + assert!(!windows_pdf_loads(&dll)); + // Present but not loadable counts as missing. + std::fs::create_dir(dir.path().join("System32")).unwrap(); + std::fs::write(&dll, b"MZ").unwrap(); + assert!(!windows_pdf_loads(&dll)); + let root = std::env::var_os("SystemRoot").unwrap(); + // The real DLL may be missing or unloadable on a stripped image; the check follows the loader. + let system = windows_pdf_dll(std::path::Path::new(&root)); + assert_eq!(require_windows_pdf().is_ok(), windows_pdf_loads(&system)); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn the_windows_pdf_check_is_a_no_op_elsewhere() { + assert!(require_windows_pdf().is_ok()); +} + +/// A 500 × 1000 pt document (72 ppi) on A3 (841.89 × 1190.55 pt): its height sets the fit. +fn a3_layout(extra: Value) -> Result { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + let mut p = json!({"paper": "a3", "scaleToFit": true}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + layout(&s.active().unwrap().doc, &p, "file.print") +} + +#[test] +fn scale_to_fit_fills_a_borderless_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // Without a printable area, the fixed 18 pt margin stays (CUPS, PDF). + let l = a3_layout(json!({})).unwrap(); + assert!((l.scale - (ph - 36.0) / 1000.0).abs() < 1e-9, "{l:?}"); + // Borderless: the driver reports the whole sheet (#7782: a Canon PRO-1000 reports its A3 as + // 842.29 × 1191.14 pt), and the image reaches the paper's edges. + let l = a3_layout(json!({"printable": [0.0, 0.0, 842.29, 1191.14]})).unwrap(); + assert!((l.scale - ph / 1000.0).abs() < 1e-9, "{l:?}"); + let (x, y, w, h) = l.rect; + assert!(y.abs() < 1e-9 && (h - ph).abs() < 1e-9 && (x - (pw - w) / 2.0).abs() < 1e-9, "{l:?}"); +} + +#[test] +fn scale_to_fit_stays_inside_an_uneven_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // 17 pt left and right, 14 pt at the top, 48 pt at the bottom (from the top-left, as + // `file.print.page` reports it). The layout's rect has its origin at the bottom-left. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0]})).unwrap(); + assert!((l.scale - (ph - 62.0) / 1000.0).abs() < 1e-9, "{l:?}"); + let (x, y, w, h) = l.rect; + assert!((y - 48.0).abs() < 1e-9 && (y + h - (ph - 14.0)).abs() < 1e-9, "fills the printable height: {l:?}"); + assert!((x - (pw - w) / 2.0).abs() < 1e-9, "centred between equal side margins: {l:?}"); + // Crop and registration marks keep their 36 pt inset, inside the printable area. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0], "cornerCropMarks": true})).unwrap(); + assert!((l.scale - (ph - 62.0 - 72.0) / 1000.0).abs() < 1e-9, "{l:?}"); + assert!((l.rect.1 - 84.0).abs() < 1e-9, "{l:?}"); + // Scale to fit off: the printable area changes nothing. + let l = a3_layout(json!({"printable": [17.0, 14.0, pw - 34.0, ph - 62.0], "scaleToFit": false})).unwrap(); + assert_eq!(l, a3_layout(json!({"scaleToFit": false})).unwrap()); +} + +#[test] +fn a_bad_printable_area_is_refused() { + let portrait = json!([0.0, 0.0, 842.29, 1191.14]); + for bad in [ + json!([0, 0, 100]), + json!([0, 0, 0, 100]), + json!([0, 0, 100, -5]), + json!("whole"), + json!([2000, 0, 100, 100]), + json!([0, "oops", 0, 842.29, 1191.14]), + json!([0, 0, 842.29, null]), + ] { + assert!(a3_layout(json!({ "printable": bad })).is_err(), "{bad}"); + } + // A portrait sheet's area on a landscape layout is another paper, not a rounding error. + assert!(a3_layout(json!({"printable": portrait, "orientation": "landscape"})).is_err()); +} + +#[test] +fn emulsion_down_fits_the_mirrored_printable_area() { + let (pw, ph) = (841.89, 1190.55); + // 10 pt on the left, 40 pt on the right of the sheet as the driver sees it. The page is + // mirrored as it prints, so the image box (before mirroring) must fit the mirror image. + // A wide image, so the printable width sets the fit. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 500, "resolution": 72})).unwrap(); + let p = json!({"paper": "a3", "scaleToFit": true, "printable": [10.0, 0.0, pw - 50.0, ph], "emulsionDown": true}); + let l = layout(&s.active().unwrap().doc, &p, "file.print").unwrap(); + let (x, _, w, _) = l.rect; + let printed = (pw - x - w, pw - x); + assert!(printed.0 >= 10.0 - 1e-9 && printed.1 <= pw - 40.0 + 1e-9, "printed at {printed:?}: {l:?}"); +} + +/// A native print fits only the driver's area: one the caller passed is dropped when the +/// driver can't give one for this paper (no such paper here, or no driver at all). +#[test] +fn a_native_print_drops_a_printable_area_the_driver_did_not_give() { + let p = with_driver_printable(&json!({"paper": [500.0, 500.0], "printable": [0.0, 0.0, 100.0, 100.0], "scaleToFit": true})); + assert!(p.get("printable").is_none(), "{p}"); + assert_eq!(p["paper"], json!([500.0, 500.0])); +} + +#[test] +fn a_print_uses_the_printable_area_but_does_not_remember_it() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0.0, 0.0, 842.29, 1191.14], "dryRun": true})).unwrap(); + assert!((r["scale"].as_f64().unwrap() - 119.055).abs() < 1e-6, "{r}"); + // The next print (Print One Copy, or the dialog on another printer or to PDF) reads its own. + let last = last_print(&s).unwrap(); + assert!(last.get("printable").is_none() && last["scaleToFit"] == true, "{last}"); +} + +#[test] +fn a_rejected_printable_area_fails_the_print_and_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 500, "height": 1000, "resolution": 72})).unwrap(); + s.execute("file.print", json!({"paper": "a3", "scale": 50, "dryRun": true})).unwrap(); + let before = (last_print(&s), s.prefs.get().dialogs.get(REMEMBERED).cloned(), s.active().unwrap().doc.clone()); + for printable in [json!([2000, 0, 100, 100]), json!([0, "oops", 0, 842.29, 1191.14])] { + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": printable, "dryRun": true})); + assert!(r.is_err(), "{printable}: {r:?}"); + } + assert!(before == (last_print(&s), s.prefs.get().dialogs.get(REMEMBERED).cloned(), s.active().unwrap().doc.clone())); + // A print at a manual scale doesn't read the area, so a stale one doesn't fail it. + let r = s.execute("file.print", json!({"paper": "a3", "scale": 50, "printable": [0.0, 0.0, 842.29, 1191.14], "orientation": "landscape", "dryRun": true})); + assert!(r.is_ok(), "{r:?}"); + // Marks need room only to fit: a small manual scale in a 60 pt-wide area prints. + let r = s.execute("file.print", json!({"paper": "a3", "scale": 5, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); + assert!(r.is_ok(), "{r:?}"); + let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); + assert!(r.is_err(), "{r:?}"); +} + +#[test] +fn package_of_a_document_named_after_a_windows_device_is_written() { + // On Windows a folder named `nul` is the NUL device: the save failed with "path not found". + let dir = tmp("package-device-name"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "nul.psd"})).unwrap(); + let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap(); + assert_eq!(r["folder"].as_str().unwrap(), format!("{dir}/pkg/nul_")); + let document = r["document"].as_str().unwrap(); + assert!(document.ends_with("pkg/nul_/nul_.pcraft"), "{document}"); + assert!(std::path::Path::new(document).is_file()); +} + +/// A dry-run print to `name`.pdf with `extra` params; returns the result and the PDF content stream. +fn print_with(s: &mut Session, dir: &str, name: &str, extra: Value) -> (Value, Vec) { + let mut p = json!({"output": format!("{dir}/{name}.pdf"), "dryRun": true, "paper": "a4", "center": false, "left": 1.0, "top": 1.0}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute("file.print", p).unwrap(); + let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); + (r, pdf) +} + +#[test] +fn zero_bleed_prints_exactly_as_before() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-zero"); + let (plain, a) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + let (zero, b) = print_with(&mut s, &dir, "zero", json!({"cornerCropMarks": true, "bleed": 0.0})); + assert_eq!(a, b, "bleed 0 must not change a single byte"); + assert_eq!(plain["bleed"], 0.0); + assert_eq!(zero["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_puts_corner_crop_marks_on_the_trim_line_outside_the_image() { + let mut s = session("rgb", 8); + let dir = tmp("bleed"); + let (r, pdf) = print_with(&mut s, &dir, "bleed", json!({"cornerCropMarks": true, "centerCropMarks": true, "bleed": 0.125})); + assert_eq!(r["bleed"], 0.125); + let rect: Vec = r["imageRect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let (x, y, w, h) = (rect[0], rect[1], rect[2], rect[3]); + let c = pdf_content(&pdf); + // Bottom-left corner (PDF is bottom-up): the horizontal mark runs left from 6 pt outside the + // image, 9 pt (0.125 in) above the image's bottom edge; the vertical one 9 pt right of its left edge. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x - 6.0, y + 9.0, x - 24.0, y + 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + 9.0, y - 6.0, x + 9.0, y - 24.0)), "{c}"); + // Top-right corner, mirrored. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w + 6.0, y + h - 9.0, x + w + 24.0, y + h - 9.0)), "{c}"); + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w - 9.0, y + h + 6.0, x + w - 9.0, y + h + 24.0)), "{c}"); + // Centre crop marks are on the midlines, which a symmetric bleed does not move. + assert!(c.contains(&format!("{:.2} {:.2} m {:.2} {:.2} l S", x + w / 2.0, y - 6.0, x + w / 2.0, y - 24.0)), "{c}"); + // Print One Copy repeats the last print, bleed included. + let again = s.execute("file.printOneCopy", json!({"output": format!("{dir}/again.pdf"), "dryRun": true})).unwrap(); + assert_eq!(again["bleed"], 0.125); + // The image box itself is unchanged: bleed moves marks, not pixels. + let (plain, _) = print_with(&mut s, &dir, "plain", json!({"cornerCropMarks": true})); + assert_eq!(plain["imageRect"], r["imageRect"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bleed_without_corner_crop_marks_changes_nothing() { + let mut s = session("rgb", 8); + let dir = tmp("bleed-nomarks"); + let (_, a) = print_with(&mut s, &dir, "plain", json!({"registrationMarks": true})); + let (r, b) = print_with(&mut s, &dir, "bleed", json!({"registrationMarks": true, "bleed": 0.1})); + assert_eq!(a, b); + assert_eq!(r["bleed"], 0.0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bad_bleed_is_an_error_not_a_panic() { + let mut s = session("rgb", 8); + // The test document prints 300 × 150 pt, so 1.05 in (75.6 pt) leaves no trim box. + for bleed in [json!(-0.1), json!("0.1"), json!(true), json!([0.1]), json!(1.05), json!(1e308)] { + let r = s.execute("file.print", json!({"dryRun": true, "send": false, "output": "", "cornerCropMarks": true, "bleed": bleed})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "bleed {bleed}: {r:?}"); + } +} + +#[test] +fn print_strings_keep_winansi_accents_and_punctuation() { + assert_eq!(pdf_string("Café – résumé"), r"(Caf\351 \226 r\351sum\351)"); + assert_eq!(pdf_string("€ ‘quote’ “double” • Œœ Šš Žž Ÿ"), r"(\200 \221quote\222 \223double\224 \225 \214\234 \212\232 \216\236 \237)"); + assert_eq!(pdf_string("é7"), r"(\3517)", "octal escapes must have three digits before a digit"); +} + +#[test] +fn print_strings_keep_pdf_delimiters_safe_and_fallback_bounded() { + assert_eq!(pdf_string(r"a(b)\c"), r"(a\(b\)\\c)"); + assert_eq!(pdf_string("\n\r\t\0\u{7f}\u{81}中😀"), "(????????)"); + assert_eq!(pdf_string(""), "()"); +} + +#[test] +fn printed_pdf_uses_winansi_for_labels_and_descriptions() { + let mut page = page_of(3, vec![255; 6], false); + page.label = Some("Café – résumé.psd".into()); + page.description = Some("Price: €20 (proof)".into()); + let pdf = print_pdf(&page); + assert!(String::from_utf8_lossy(&pdf).contains("/Encoding /WinAnsiEncoding")); + let content = pdf_content(&pdf); + assert!(content.contains(r"(Caf\351 \226 r\351sum\351.psd) Tj"), "{content}"); + assert!(content.contains(r"(Price: \20020 \(proof\)) Tj"), "{content}"); + assert!(content.is_ascii(), "encoded bytes are escaped, never UTF-8 text operands"); +} diff --git a/crates/engine/src/print_cmds/windows_marks.rs b/crates/engine/src/print_cmds/windows_marks.rs index 857e6335a9..b78d4234b9 100644 --- a/crates/engine/src/print_cmds/windows_marks.rs +++ b/crates/engine/src/print_cmds/windows_marks.rs @@ -1,5 +1,5 @@ //! App-owned print-mark composition in points from the sheet's top-left. -use super::Marks; +use super::{MARK_GAP, MARK_LEN, Marks, corner_crop_segments}; use craft_print::rgb::{Primitive, Shape}; pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Vec { @@ -7,11 +7,10 @@ pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Ve let line = |out: &mut Vec, x0, y0, x1, y1| { out.push(Primitive { shape: Shape::Line { from: (x0, y0), to: (x1, y1) }, width: 0.3, color: [0; 3] }); }; - let (gap, len) = (6.0, 18.0); + let (gap, len) = (MARK_GAP, MARK_LEN); if marks.corner_crop { - for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { - line(&mut out, cx + dx * gap, cy, cx + dx * (gap + len), cy); - line(&mut out, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + for ((x0, y0), (x1, y1)) in corner_crop_segments(marks.bleed, (x, y, w, h)) { + line(&mut out, x0, y0, x1, y1); } } if marks.center_crop { @@ -37,11 +36,24 @@ mod tests { use super::*; #[test] fn marks_surround_the_final_image_box_in_physical_points() { - let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true }, (100.0, 200.0, 144.0, 72.0)); + let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true, bleed: 0.0 }, (100.0, 200.0, 144.0, 72.0)); assert_eq!(out.len(), 24); assert_eq!(out[0].shape, Shape::Line { from: (94.0, 200.0), to: (76.0, 200.0) }); assert_eq!(out[12].shape, Shape::Ellipse { bounds: (167.0, 180.0, 10.0, 10.0) }); assert!(out.iter().all(|p| p.width == 0.3 && p.color == [0; 3])); assert!(primitives(Marks::default(), (0.0, 0.0, 10.0, 10.0)).is_empty()); } + + #[test] + fn bleed_moves_corner_crop_marks_onto_the_trim_line_in_top_down_coordinates() { + let rect = (100.0, 200.0, 144.0, 72.0); + let out = primitives(Marks { corner_crop: true, bleed: 9.0, ..Marks::default() }, rect); + assert_eq!(out.len(), 8); + // Top-left corner: still outside the image, but on the trim line 9 pt in (y down here). + assert_eq!(out[0].shape, Shape::Line { from: (94.0, 209.0), to: (76.0, 209.0) }); + assert_eq!(out[1].shape, Shape::Line { from: (109.0, 194.0), to: (109.0, 176.0) }); + // Bottom-right corner, mirrored. + assert_eq!(out[6].shape, Shape::Line { from: (250.0, 263.0), to: (268.0, 263.0) }); + assert_eq!(out[7].shape, Shape::Line { from: (235.0, 278.0), to: (235.0, 296.0) }); + } } diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index f225a5a7a9..6c7afc15d9 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -18,6 +18,7 @@ use photocraft_cms::{Builtin, Intent, Lut3d, Transform}; use photocraft_color::SampleType; +use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -91,14 +92,16 @@ impl Default for HdrPreview { } impl HdrPreview { + /// Whether the preview passes values through unchanged. pub fn is_identity(&self) -> bool { self.highlight_compression || (self.exposure == 0.0 && self.gamma == 1.0) } + /// One encoded channel value through the 32-bit preview's exposure and gamma. fn apply(&self, v: f32) -> f32 { if self.is_identity() { return v; } - let lin = srgb_decode(v) * 2f32.powf(self.exposure); + let lin = srgb_to_linear(v) * 2f32.powf(self.exposure); srgb_encode(lin.max(0.0).powf(1.0 / self.gamma).min(1.0)) } } @@ -115,13 +118,9 @@ pub fn signature(c: &ColorState, doc: &Document) -> String { format!("{:?} {:?}", pv.setup.kind, h) } -fn srgb_decode(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - +/// sRGB-encodes linear light after clamping it to 0..=1. fn srgb_encode(v: f32) -> f32 { - let v = v.clamp(0.0, 1.0); - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + linear_to_srgb(v.clamp(0.0, 1.0)) } /// Viénot et al. (1999): linear RGB → LMS. @@ -201,7 +200,7 @@ pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr let protan = kind == ProofKind::Protanopia; Box::new(move |v| { let s = eval3(&to_srgb, &v); - let sim = dichromat(s.map(srgb_decode), protan).map(srgb_encode); + let sim = dichromat(s.map(srgb_to_linear), protan).map(srgb_encode); eval3(&to_mon, &sim) }) } @@ -262,6 +261,7 @@ fn run_kind(s: &mut Session, id: &str) -> Result { set_kind(s, kind) } +/// View › 32-bit Preview Options for the active document. fn preview_32(s: &mut Session, p: &Value) -> Result { const CMD: &str = "view.thirtyTwoBitPreviewOptions"; let id = s.active().ok_or(EngineError::NoDocument)?.doc.id; @@ -298,6 +298,7 @@ macro_rules! kind_spec { }; } +/// The View › Proof Setup commands. pub fn specs() -> Vec { vec![ kind_spec!("view.proofSetup.workingCyanPlate", "Working Cyan Plate"), @@ -325,6 +326,21 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(srgb_encode(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + fn session(mode: &str, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8, "mode": mode, "depth": depth})).unwrap(); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..005377328f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,9 +15,10 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use rayon::prelude::*; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -26,6 +27,7 @@ use crate::{EngineError, Result, Session}; mod content_aware_move; mod patch; mod pattern_stamp; +pub use pattern_stamp::live_impressionist as live_pattern_impressionist; mod periodic; mod remove; pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; @@ -35,6 +37,64 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a +/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error +/// instead of allocating. +const MAX_STROKE_PIXELS: u64 = 67_108_864; + +/// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush +/// size alone, before the renderer allocates anything for it. +fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { + // Dynamics place dabs beyond the input points, so half the tip is not the whole reach: + // Scattering offsets a centre by up to its amount times the tip size, and a Dual Brush dab + // reaches its own radius further. Pad by what those can add so this estimate does not fall + // short of what the renderer will cover. It is only the cheap early-out; the rendered extent + // is admitted for real in `coverage` below. + let b = &stroke.brush; + let size = f64::from(b.size.max(1.0)); + let scatter = if b.scattering.enabled { size * f64::from(b.scattering.scatter.jitter.max(0.0)) } else { 0.0 }; + let dual = if b.dual_brush.enabled { f64::from(b.dual_brush.size.max(0.0)) * 0.5 } else { 0.0 }; + let pad = size.mul_add(0.5, 4.0) + scatter + dual; + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for q in &stroke.points { + min_x = min_x.min(q.x); + min_y = min_y.min(q.y); + max_x = max_x.max(q.x); + max_y = max_y.max(q.y); + } + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return Err(bad(cmd, "point coordinates must be finite")); + } + let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); + let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); + if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { + return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); + } + let area = (w as u64).saturating_mul(h as u64); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + +/// The stroke's coverage, with the **rendered** extent admitted before the dense buffer exists. +/// +/// `reject_huge_stroke` estimates from the input points; this is the real bound. Rendering is +/// tiled, so it holds only the tiles the dabs touch; the extent is read from those tiles once +/// every dab is placed (tips, dynamics and scattering included) and checked against `max`, and +/// only a stroke that fits is made dense. `max` is a parameter so tests can inject a small budget. +fn coverage_within(cmd: &str, stroke: &Stroke, max: u64) -> Result<(Rect, Vec)> { + photocraft_paint::retouch::stroke_coverage_within(stroke, max).ok_or_else(|| bad(cmd, format!("stroke too large (at most {max} px)"))) +} + +/// [`coverage_within`] at the shared allowance. +fn coverage(cmd: &str, stroke: &Stroke) -> Result<(Rect, Vec)> { + coverage_within(cmd, stroke, MAX_STROKE_PIXELS) +} + /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { if crate::channel_cmds::edits_channel(s) { @@ -312,6 +372,7 @@ fn clamp_samples(fmt: &PixelFormat, data: &mut [f32]) { fn clone_stamp(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.cloneStamp"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -336,7 +397,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -355,6 +416,9 @@ enum LivePaint { Clone { which: SampleLayers, map: crate::presets::clone_source::Mapping, cmd: &'static str }, /// The History Brush's history state. History(Surface), + /// The Pattern Stamp's pattern, tiled from its aligned placement (Impressionist strokes are + /// laid dab by dab: `pattern_stamp::live_impressionist`). + Pattern { tile: std::sync::Arc, place: photocraft_compose::pattern::Placement }, } /// A retouching stroke shown while it is drawn: the dabs so far composited onto a copy of the @@ -385,10 +449,19 @@ pub struct LiveRetouch { } impl LiveRetouch { - /// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush` or - /// `paint.historyBrush`) from its params; their `points` are rendered. + /// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush`, + /// `paint.historyBrush` or an aligned, non-Impressionist `paint.patternStamp`) from its + /// params; their `points` are rendered. pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result { has_pixel_layer(s).map_err(EngineError::Other)?; + if cmd == "paint.patternStamp" { + let st = pattern_stamp::Setup::parse(s, p)?; + if st.impressionist { + return Err(bad(cmd, "Impressionist strokes are laid dab by dab (pattern_stamp::live_impressionist)")); + } + let paint = LivePaint::Pattern { place: st.placement(), tile: st.tile }; + return Self::with_paint(s, cmd, p, st.stroke, st.id, paint, st.mode); + } let (stroke, id) = parse_brush(s, p, cmd)?; let mode = blend_param(p, cmd)?; let paint = match cmd { @@ -401,8 +474,13 @@ impl LiveRetouch { } } "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), - other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), + other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush, History Brush and Pattern Stamp")), }; + Self::with_paint(s, cmd, p, stroke, id, paint, mode) + } + + /// Start a live stroke of `cmd` painting `paint` through `stroke`'s coverage. + fn with_paint(s: &Session, cmd: &str, p: &Value, stroke: Stroke, id: Option, paint: LivePaint, mode: BlendMode) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision)); let mut doc = (*pre).clone(); @@ -544,6 +622,7 @@ impl LiveRetouch { clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, *cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false), cmd)? } LivePaint::History(src) => Region::read(src, r), + LivePaint::Pattern { tile, place } => pattern_stamp::paint_region(tile, place, &self.pre_surf.format(), r), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; surf.write_region(r, &self.pre_surf.read_region(r)); @@ -592,13 +671,14 @@ fn heal_region(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool]) -> fn healing_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.healingBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -612,16 +692,12 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { Ok(clone_result(dmg, off, aligned, p, &stroke)) } -/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in history -/// state `state` (default: the oldest state still held, the "Open" snapshot unless trimmed). +/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in History +/// panel row `state` or snapshot `snapshot` (default: the source set in the History panel, +/// `history.setSource`, else the oldest state still held, the "Open" state unless trimmed). fn history_source(s: &Session, id: Option, p: &Value, cmd: &str) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; - let explicit = p.get("state").and_then(Value::as_u64).map(|v| v as usize); - let source_doc = match explicit { - Some(i) if i == st.history.past_len() => st.doc.clone(), - Some(i) => st.history.state(i).ok_or_else(|| bad(cmd, format!("no history state {i} (0..={})", st.history.past_len())))?, - None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), - }; + let source_doc = crate::history_source_cmds::paint_source(st, p, cmd)?; crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p) .cloned() .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into())) @@ -631,6 +707,10 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + // The coverage and the source region below are both dense over the stroke's bounds, so a + // thin stroke across a large canvas would still ask for the whole box. Refuse it from the + // points and brush size, before the source surface is cloned or anything is rendered (#4961). + reject_huge_stroke(CMD, &stroke)?; let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; @@ -641,7 +721,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; @@ -718,12 +798,12 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Rect { - let (bounds, cov) = stroke_coverage(stroke); +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; @@ -740,7 +820,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); if !hole.iter().any(|h| *h) { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels // carry synthesised values that differ from the untouched surroundings: that mismatch is what the @@ -764,12 +844,13 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal) + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } fn spot_healing(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.spotHealing"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let kind = match string(p, "type", "contentAware") { "contentAware" => SpotType::ContentAware, "createTexture" => SpotType::CreateTexture, @@ -777,7 +858,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -977,19 +1058,26 @@ impl DabTool { let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize); let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) }; let rw = r.width() as usize; - for y in r.y0..r.y1 { + // Row-parallel, like the blur itself (#5426). + let (wx0, wy0) = (work.rect.x0, work.rect.y0); + work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| { + let y = wy0 + row as i32; + if y < r.y0 || y >= r.y1 { + return; + } for x in r.x0..r.x1 { let k = per_dab(fp.at(x, y) * strength, sp); if k <= 0.0 { continue; } let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n; - let px = work.px_mut(x, y); + let i = (x - wx0) as usize * n; + let px = &mut line[i..i + n]; for c in 0..n { px[c] += (out[o + c] - px[c]) * k; } } - } + }); }) } Self::Smudge { strength, finger, max_size } => { @@ -1020,6 +1108,52 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(json!({ "damage": damage_json(dmg) })) } +/// The working pixels of a [`LiveDab`]: a copy of the target in its own depth, or (`precise`) +/// float pixels copied from it a tile at a time as dabs first reach them, so only the stroke's +/// area is ever copied. +struct WorkPixels { + surf: Surface, + /// Tiles (`TILE` px) copied from the target so far; `None` when `surf` is a full copy. + filled: Option>, +} + +impl WorkPixels { + const TILE: i32 = 64; + + fn new(pre: &Surface, precise: bool) -> Self { + if precise { + let f = pre.format(); + let fmt = PixelFormat::new(f.mode, SampleType::F32, f.alpha); + Self { surf: Surface::new(fmt), filled: Some(std::collections::HashSet::new()) } + } else { + Self { surf: pre.clone(), filled: None } + } + } + + /// The working pixels of `r` (`pre` = the target before the stroke). + fn read(&mut self, pre: &Surface, r: Rect) -> Region { + if let Some(filled) = &mut self.filled + && !r.is_empty() + { + let t = Self::TILE; + for ty in r.y0.div_euclid(t)..=(r.y1 - 1).div_euclid(t) { + for tx in r.x0.div_euclid(t)..=(r.x1 - 1).div_euclid(t) { + if filled.insert((tx, ty)) { + let cell = Rect::new(tx * t, ty * t, tx * t + t, ty * t + t); + // Normalised samples, stored unrounded as floats. + self.surf.write_region(cell, &pre.read_region(cell)); + } + } + } + } + Region::read(&self.surf, r) + } + + fn write(&mut self, region: &Region) { + region.write(&mut self.surf); + } +} + /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -1033,7 +1167,7 @@ pub struct LiveDab { effect: DabEffect, halo: i32, pre_surf: Surface, - work: Surface, + work: WorkPixels, id: Option, params: Value, opacity: f32, @@ -1073,7 +1207,25 @@ impl LiveDab { halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + let doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + Self::with_effect_on(doc, cmd, p, stroke, id, halo, false, effect) + } + + /// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own + /// edit does first (the Background Eraser's Background turned into a normal layer, say). With + /// `precise` the working pixels between dabs are kept as floats, as the commit keeps them, so + /// the preview is the commit at any depth (else they are stored in the target's depth). + #[allow(clippy::too_many_arguments)] + pub(crate) fn with_effect_on( + mut doc: Document, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + halo: i32, + precise: bool, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); @@ -1087,7 +1239,7 @@ impl LiveDab { done: 0, effect, halo, - work: pre_surf.clone(), + work: WorkPixels::new(&pre_surf, precise), pre_surf, id, params: p.clone(), @@ -1127,9 +1279,9 @@ impl LiveDab { if r.is_empty() { continue; } - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - region.crop(r).write(&mut self.work); + self.work.write(®ion.crop(r)); dmg = dmg.union(&r); } self.done += new.len(); @@ -1139,7 +1291,7 @@ impl LiveDab { let ds = self.renderer.tail_dabs(); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { - let mut region = Region::read(&self.work, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); for (k, d) in ds.iter().enumerate() { (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); } @@ -1156,7 +1308,7 @@ impl LiveDab { self.bounds = self.bounds.union(&dmg).union(&tail_rect); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; if !shown.is_empty() { - let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown)); + let (orig, work) = (Region::read(&self.pre_surf, shown), self.work.read(&self.pre_surf, shown)); photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); } if let Some((r, work)) = tail { @@ -1363,7 +1515,9 @@ pub fn specs() -> Vec { label: "History Brush", menu: &[], shortcut: None, - params: brush_params!(r#","state":index (into history.entries; default 0 = the oldest held state, normally the Open snapshot)"#), + params: brush_params!( + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# + ), enabled: has_pixel_layer, run: history_brush, journal: true, diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index ddca9c6e0f..efa63e20b2 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -149,31 +149,36 @@ fn mapped_bounds(h: &Homography, r: Rect) -> Rect { } /// `src` resampled (bilinear, edges clamped) through `inv` (destination → source) over `rect`: -/// the content as it lands. Works on float samples, so at any depth. +/// the content as it lands. Works on float samples, so at any depth; rows in parallel. fn resample(src: &Region, inv: &Homography, rect: Rect) -> Region { + use rayon::prelude::*; let (sw, sh, n) = (src.rect.width() as usize, src.rect.height() as usize, src.ch); - let mut out = Region { rect, ch: n, data: vec![0.0; rect.width() as usize * rect.height() as usize * n] }; - if sw == 0 || sh == 0 { + let w = rect.width() as usize; + let mut out = Region { rect, ch: n, data: vec![0.0; w * rect.height() as usize * n] }; + if sw == 0 || sh == 0 || w == 0 || n == 0 { return out; } let px = |x: usize, y: usize, c: usize| src.data.get((y * sw + x) * n + c).copied().unwrap_or(0.0); - for (i, dst) in out.data.chunks_exact_mut(n).enumerate() { - let (qx, qy) = (rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); - let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); - let fx = (sx - 0.5 - f64::from(src.rect.x0)).clamp(0.0, (sw - 1) as f64); - let fy = (sy - 0.5 - f64::from(src.rect.y0)).clamp(0.0, (sh - 1) as f64); - if !(fx.is_finite() && fy.is_finite()) { - continue; + out.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| { + let qy = rect.y0 + row as i32; + for (col, dst) in line.chunks_exact_mut(n).enumerate() { + let qx = rect.x0 + col as i32; + let (sx, sy) = inv.apply(f64::from(qx) + 0.5, f64::from(qy) + 0.5); + let fx = (sx - 0.5 - f64::from(src.rect.x0)).clamp(0.0, (sw - 1) as f64); + let fy = (sy - 0.5 - f64::from(src.rect.y0)).clamp(0.0, (sh - 1) as f64); + if !(fx.is_finite() && fy.is_finite()) { + continue; + } + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + for (c, d) in dst.iter_mut().enumerate() { + let top = px(x0, y0, c) * (1.0 - tx) + px(x1, y0, c) * tx; + let bottom = px(x0, y1, c) * (1.0 - tx) + px(x1, y1, c) * tx; + *d = top * (1.0 - ty) + bottom * ty; + } } - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); - let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); - for (c, d) in dst.iter_mut().enumerate() { - let top = px(x0, y0, c) * (1.0 - tx) + px(x1, y0, c) * tx; - let bottom = px(x0, y1, c) * (1.0 - tx) + px(x1, y1, c) * tx; - *d = top * (1.0 - ty) + bottom * ty; - } - } + }); out } @@ -217,18 +222,26 @@ fn plan(s: &Session, p: &Value, margin: i32) -> Result { return Err(bad(CMD, format!("the selection is too large to move ({pixels} pixels, at most {MAX_PIXELS})"))); } let map = placement(p, area, (dx, dy))?; - let dest = match &map { + if map.is_none() && (dx, dy) == (0, 0) { + return Err(bad(CMD, "drag the selection to where the content should go")); + } + let dest = landing(map.as_ref(), area, (dx, dy), canvas)?; + let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) +} + +/// Where the content of `area` lands on `canvas`: moved by `offset`, or (Transform On Drop) the +/// bounds of the area mapped by `map`, clipped to the canvas. +fn landing(map: Option<&Homography>, area: Rect, (dx, dy): (i32, i32), canvas: Rect) -> Result { + match map { None => { - if (dx, dy) == (0, 0) { - return Err(bad(CMD, "drag the selection to where the content should go")); - } // Content dropped beyond the edge would be lost: keep it on the canvas, as the Patch // Tool does. let dest = area.translate(dx, dy); if !canvas.contains_rect(&dest) { return Err(bad(CMD, "the moved selection must stay inside the canvas")); } - dest + Ok(dest) } Some(h) => { let landed = mapped_bounds(h, area); @@ -240,11 +253,9 @@ fn plan(s: &Session, p: &Value, margin: i32) -> Result { if dest.is_empty() { return Err(bad(CMD, "the transformed selection must land on the canvas")); } - dest + Ok(dest) } - }; - let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) + } } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -528,18 +539,24 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { ) } -/// Largest selection (bounding box, in pixels) the live preview computes. +/// Largest area (the selection's bounding box, or where it lands if that is larger, in pixels) a +/// preview solves at full resolution. A larger one is solved on a copy scaled down to about this +/// size ([`ContentAwarePreview::is_coarse`]). const PREVIEW_MAX_PIXELS: u64 = 256 * 256; /// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve. const PREVIEW_MARGIN: i32 = 48; /// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of -/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and -/// only small selections), so the result is close to, not the same as, what the command makes. +/// [`preview`], with the move's params. Cheaper than the command: a small sampling margin, and a +/// selection larger than [`PREVIEW_MAX_PIXELS`] is solved scaled down and enlarged back (coarse). +/// The result is close to, not the same as, what the command makes; [`Self::refine`] gives the +/// command's own result for a coarse one. pub struct ContentAwarePreview { doc: std::sync::Arc, plan: Plan, params: Value, + /// Below 1: the scale the move is solved at before it is enlarged back. + scale: f64, } impl ContentAwarePreview { @@ -548,19 +565,226 @@ impl ContentAwarePreview { pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> { let mut work = (*self.doc).clone(); let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?; + if self.is_coarse() { + let damage = run_scaled(&mut work, sel, &self.plan, &self.params, self.scale, ctx)?; + return Ok((work, damage)); + } let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; Ok((work, damage)) } + + /// Whether the preview is solved on a scaled-down copy (a selection larger than a preview + /// solves at full resolution): fast, but softer than the result. + pub fn is_coarse(&self) -> bool { + self.scale < 1.0 + } + + /// For a coarse preview, the full-resolution one: exactly what `paint.contentAwareMove` makes + /// (the command's sampling margin), and as slow. `None` when this preview already is at full + /// resolution. + pub fn refine(&self) -> Option { + self.is_coarse().then(|| ContentAwarePreview { + doc: self.doc.clone(), + plan: Plan { margin: MAX_MARGIN, ..self.plan }, + params: self.params.clone(), + scale: 1.0, + }) + } } /// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors -/// like the command does, and for a selection larger than a preview takes. +/// as the command does (bad params, nothing to move, a selection larger than the command takes). pub fn preview(s: &Session, p: &Value) -> Result { let plan = plan(s, p, PREVIEW_MARGIN)?; - let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height()); - if pixels > PREVIEW_MAX_PIXELS { - return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})"))); - } + let px = |r: Rect| u64::from(r.width()) * u64::from(r.height()); + let pixels = px(plan.area).max(px(plan.dest)); + let scale = if pixels > PREVIEW_MAX_PIXELS { (PREVIEW_MAX_PIXELS as f64 / pixels as f64).sqrt() } else { 1.0 }; let d = s.active().ok_or(EngineError::NoDocument)?; - Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() }) + Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone(), scale }) +} + +/// The surface's tiles over `r` (shared, not copied) on a surface of their own: what a resize of +/// that part reads. +fn crop(surf: &Surface, r: Rect) -> Surface { + let mut all = surf.clone(); + let mut out = Surface::with_default(surf.format(), &surf.default_pixel()); + out.put_tiles(all.take_tiles(r)); + out +} + +/// A translation by `(dx, dy)`. +fn shift(dx: f64, dy: f64) -> Homography { + Homography([1.0, 0.0, dx, 0.0, 1.0, dy, 0.0, 0.0, 1.0]) +} + +/// [`run_move`] solved `scale` times smaller (`scale` < 1) and enlarged back: the area around the +/// selection and where it lands is scaled down (area average), moved there with a preview's +/// sampling margin, and the result enlarged (bilinear) over the selection's shape where it lands +/// and, for a move, where it left. The landed content is sampled where the scaled solve put it, so +/// rounding the scaled offset doesn't shift it. Writes into `doc` (a copy) and returns the +/// rectangle it changed. +fn run_scaled(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, scale: f64, ctx: &crate::jobs::JobCtx) -> Result { + use photocraft_algo::resample::{Resample, resize_surface_in_canvas, scaled_rect}; + let f = scale; + if !(f > 0.0 && f < 1.0) { + return Err(EngineError::Other("internal error: a coarse preview needs a scale below 1".into())); + } + let (dx, dy) = plan.offset; + let full_map = plan.map.unwrap_or_else(|| shift(f64::from(dx), f64::from(dy))); + let full_inv = full_map.inverse().ok_or_else(|| bad(CMD, "the transform collapses the selection"))?; + // What the scaled solve reads: both windows, with two scaled pixels to spare for the filter. + let reach = (f64::from(PREVIEW_MARGIN + 2) / f).ceil().min(1e6) as i32; + let reach_rect = plan.area.union(&plan.dest).inflate(reach).intersect(&plan.canvas); + ctx.check()?; + + // 1. The pixels and the selection scaled down, on a document of their own (one layer). + let fmt = crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0.format(); + let src = if plan.target.all_layers { + let region = read(doc, plan.target, p, reach_rect)?; + let mut s = Surface::new(fmt); + s.write_region(reach_rect, ®ion.data); + s + } else { + crop(crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0, reach_rect) + }; + let small = resize_surface_in_canvas(&src, f, f, Resample::Bilinear, plan.canvas); + // The selection is scaled in 32-bit float: in 8 bits a faint, sparse one (scattered 1/255 + // pixels) averages to 0 and the scaled solve would find nothing selected. + let sel_f32 = photocraft_color::PixelFormat { sample: photocraft_color::SampleType::F32, ..sel.format() }; + let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect).convert(sel_f32), f, f, Resample::Bilinear, plan.canvas); + ctx.check()?; + let canvas = scaled_rect(plan.canvas, f, f); + let size = photocraft_geom::Size { width: canvas.width(), height: canvas.height() }; + let mut mini = Document::new("Content-Aware Move preview", size, doc.mode, doc.depth); + let layer = photocraft_doc::Layer::new("preview", LayerContent::Raster(small)); + let id = layer.id; + mini.layers.push(layer); + + // 2. The move, scaled. + let area = selection_area(&small_sel, canvas); + if area.is_empty() { + return Err(bad(CMD, "the selection is empty")); + } + let mut offset = ((f64::from(dx) * f).round() as i32, (f64::from(dy) * f).round() as i32); + if plan.map.is_none() { + // Rounding may push the scaled patch a pixel past the edge: keep it on the canvas. + let moved = area.translate(offset.0, offset.1); + offset.0 += (canvas.x0 - moved.x0).max(0) - (moved.x1 - canvas.x1).max(0); + offset.1 += (canvas.y0 - moved.y0).max(0) - (moved.y1 - canvas.y1).max(0); + } + let map = match plan.map { + Some(_) => placement(p, area, offset)?, + None => None, + }; + let dest = landing(map.as_ref(), area, offset, canvas)?; + let small_plan = Plan { target: Target { id: Some(id), all_layers: false }, canvas, area, offset, map, dest, margin: PREVIEW_MARGIN, ..*plan }; + // The small document's layer is the target: no channel or mask targeting there. + let mut small_p = p.clone(); + if let Some(o) = small_p.as_object_mut() { + o.remove("target"); + } + let (solved, _, _) = run_move(&mut mini, &small_sel, &small_plan, &small_p, ctx, "Content-Aware Move preview")?; + ctx.check()?; + + // 3. Enlarged back over the selection's shape: where it lands (through the scaled placement) + // and, for a move, where it left (in place). + let solved = Region::read(&solved, scaled_rect(reach_rect, f, f).inflate(1).intersect(&canvas)); + let to_small = Homography([f, 0.0, 0.0, 0.0, f, 0.0, 0.0, 0.0, 1.0]); + let small_map = map.unwrap_or_else(|| shift(f64::from(offset.0), f64::from(offset.1))); + let cov = if plan.map.is_some() { coverage(sel, plan.area) } else { Vec::new() }; + let landed_in = |r: Rect| -> Vec { + match &plan.map { + None => mask(sel, r.translate(-dx, -dy)), + Some(_) => landed_mask(&cov, plan.area, &full_inv, r), + } + }; + let landed = landed_in(plan.dest); + let content = resample(&solved, &small_map.mul(&to_small).mul(&full_inv), plan.dest); + let mut damage = replace(doc, plan.target, p, &content, &landed)?; + if plan.mode == Mode::Move { + ctx.check()?; + let hole: Vec = mask(sel, plan.area).iter().zip(landed_in(plan.area)).map(|(s, l)| *s && !l).collect(); + if hole.iter().any(|h| *h) { + damage = damage.union(&replace(doc, plan.target, p, &resample(&solved, &to_small, plan.area), &hole)?); + } + } + Ok(damage) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::JobCtx; + + fn textured(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let v = ((x * 7 + y * 13) % 17) as f32 / 17.0; + data.extend(photocraft_raster::from_rgba(&fmt, [v, 1.0 - v, 0.5, 1.0])); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s + } + + #[test] + fn a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit() { + let mut s = textured(300, 300); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 60, "height": 50})).unwrap(); + for p in [json!({"offset": [120, 90]}), json!({"offset": [120, 90], "rotate": 30, "mode": "extend"})] { + let job = preview(&s, &p).unwrap(); + assert!(!job.is_coarse()); + let (got, damage) = job.run(&JobCtx::new()).unwrap(); + // What the preview has always computed: the move at full resolution, with the + // preview's margin. + let plan = plan(&s, &p, PREVIEW_MARGIN).unwrap(); + let d = s.active().unwrap(); + let mut want = (*d.doc).clone(); + let (_, want_damage, _) = run_move(&mut want, d.doc.selection.as_ref().unwrap(), &plan, &p, &JobCtx::new(), "test").unwrap(); + let b = want.bounds(); + assert_eq!(damage, want_damage); + assert!(got.layers[0].surface().unwrap().read_region(b) == want.layers[0].surface().unwrap().read_region(b), "{p}"); + } + // 256 × 256 is the largest selection solved at full resolution. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 256, "height": 256})).unwrap(); + assert!(!preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse()); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 257, "height": 256})).unwrap(); + assert!(preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse()); + } + + #[test] + fn a_coarse_preview_keeps_a_faint_sparse_selection() { + // An 8-bit selection of scattered 1/255 pixels: scaled down in 8 bits it averaged to 0 + // everywhere, so the coarse preview failed with "the selection is empty" while the + // command itself moves it. + let mut s = textured(1200, 800); + s.edit("faint selection", |doc, _| { + let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8); + let r = Rect::new(20, 20, 1120, 720); + let data: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| if x % 4 == 0 && y % 4 == 0 { 1.0 / 255.0 } else { 0.0 })).collect(); + sel.write_region(r, &data); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + let p = json!({"offset": [40, 40]}); + let job = preview(&s, &p).unwrap(); + assert!(job.is_coarse()); + let (got, damage) = job.run(&JobCtx::new()).unwrap(); + assert!(!damage.is_empty()); + let b = got.bounds(); + let before = s.active().unwrap().doc.layers[0].surface().unwrap().read_region(b); + assert!(got.layers[0].surface().unwrap().read_region(b) != before, "the preview moves something"); + s.execute("paint.contentAwareMove", p).unwrap(); + } } diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 0dc9f102ac..6f14ac8eb0 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -6,21 +6,16 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; -use photocraft_doc::Pattern; +use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; -/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a -/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error -/// instead of allocating. -const MAX_STROKE_PIXELS: u64 = 67_108_864; - const STREAM_PHASE_X: u64 = 101; const STREAM_PHASE_Y: u64 = 102; @@ -29,53 +24,110 @@ pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> { if crate::pattern_cmds::resolve(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) } } +/// A parsed `paint.patternStamp`: shared by the command and its live preview, so both paint the +/// same stroke (seeded the same way) with the same tile and placement. +pub(super) struct Setup { + pub(super) stroke: Stroke, + pub(super) id: Option, + pub(super) mode: BlendMode, + aligned: bool, + pub(super) impressionist: bool, + pat: Pattern, + pub(super) tile: std::sync::Arc, + phase: (f32, f32), + scale: f32, + angle: f32, + seed: u64, +} + +impl Setup { + pub(super) fn parse(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.patternStamp"; + let (mut stroke, id) = parse_brush(s, p, CMD)?; + let mode = blend_param(p, CMD)?; + let aligned = flag(p, "aligned", true); + let impressionist = flag(p, "impressionist", false); + let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; + if pat.is_empty() { + return Err(bad(CMD, "the pattern is empty")); + } + let scale = stamp_scale(p, CMD)?; + let angle = stamp_angle(p, CMD)?; + let Some(first) = stroke.points.first() else { + return Err(bad(CMD, "`points` is empty")); + }; + let phase = stamp_phase(p, CMD, aligned, first)?; + let tile = std::sync::Arc::new(Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty or too large to paint"))?); + reject_huge_stroke(CMD, &stroke)?; + let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| { + let bytes = p.get("points").map(std::string::ToString::to_string).unwrap_or_default(); + photocraft_paint::rng::seed_from_bytes(bytes.as_bytes()) + }); + stroke.brush.seed = seed; + Ok(Self { stroke, id, mode, aligned, impressionist, pat, tile, phase, scale, angle, seed }) + } + + /// Where the pattern sits (aligned strokes; Impressionist jitters it per dab). + pub(super) fn placement(&self) -> Placement { + placement(self.phase, self.scale, self.angle) + } + + /// One Impressionist dab's edit of a working copy in `fmt`. + fn impressionist_effect(&self, fmt: PixelFormat) -> super::DabEffect { + let (tile, phase, scale, angle, seed, mode) = (self.tile.clone(), self.phase, self.scale, self.angle, self.seed, self.mode); + let (tw, th) = (self.pat.width as f32 * scale, self.pat.height as f32 * scale); + Box::new(move |work, fp| stamp_dab(work, &fmt, &tile, fp, phase, scale, angle, seed, tw, th, mode)) + } +} + pub(super) fn run(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.patternStamp"; - let (mut stroke, id) = parse_brush(s, p, CMD)?; - let mode = blend_param(p, CMD)?; - let aligned = flag(p, "aligned", true); - let impressionist = flag(p, "impressionist", false); - let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; - if pat.is_empty() { - return Err(bad(CMD, "the pattern is empty")); - } - let scale = stamp_scale(p, CMD)?; - let angle = stamp_angle(p, CMD)?; - let Some(first) = stroke.points.first() else { - return Err(bad(CMD, "`points` is empty")); - }; - let phase = stamp_phase(p, CMD, aligned, first)?; - let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty or too large to paint"))?; - reject_huge_stroke(CMD, &stroke)?; - - let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| { - let bytes = p.get("points").map(std::string::ToString::to_string).unwrap_or_default(); - photocraft_paint::rng::seed_from_bytes(bytes.as_bytes()) - }); - stroke.brush.seed = seed; - + let st = Setup::parse(s, p)?; let dmg = s.edit("Pattern Stamp", |doc, _| { - crate::pattern_cmds::ensure_in_doc(doc, &pat); + crate::pattern_cmds::ensure_in_doc(doc, &st.pat); let pre = doc.clone(); - let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); - if impressionist { - paint_impressionist(surf, &stroke, sel, lock, &tile, &pat, phase, scale, angle, seed, mode) + if st.impressionist { + // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. + let effect = st.impressionist_effect(surf.format()); + Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) } else { - paint_aligned(surf, &stroke, sel, lock, &tile, phase, scale, angle, mode, CMD) + paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(dmg); + if let Some(state) = s.active_mut() { + state.last_damage = Some(dmg); } Ok(json!({ "damage": damage_json(dmg), - "phase": [phase.0, phase.1], - "aligned": aligned, - "pattern": pat.id, + "phase": [st.phase.0, st.phase.1], + "aligned": st.aligned, + "pattern": st.pat.id, })) } +/// A Pattern Stamp stroke with Impressionist on, shown while it is drawn: the same jittered dabs, +/// laid one by one as the commit lays them (`apply_dab_stroke`), so the preview at the last point +/// is the committed result. Aligned strokes are live through [`super::LiveRetouch`]. +pub fn live_impressionist(s: &Session, p: &Value) -> Result { + const CMD: &str = "paint.patternStamp"; + has_pixel_layer(s).map_err(crate::EngineError::Other)?; + let st = Setup::parse(s, p)?; + if !st.impressionist { + return Err(bad(CMD, "only Impressionist strokes are laid dab by dab; aligned strokes preview through LiveRetouch")); + } + let (stroke, id) = (st.stroke.clone(), st.id); + let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, true, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() +} + +/// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints +/// through its coverage. +pub(super) fn paint_region(tile: &Tile, place: &Placement, fmt: &PixelFormat, rect: Rect) -> Region { + rgba_to_region(fmt, rect, &render(tile, place, rect)) +} + fn stamp_scale(p: &Value, cmd: &str) -> Result { match p.get("scale") { None => Ok(1.0), @@ -123,45 +175,6 @@ fn stamp_phase(p: &Value, cmd: &str, aligned: bool, first: &StrokePoint) -> Resu } } -fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for q in &stroke.points { - min_x = min_x.min(q.x); - min_y = min_y.min(q.y); - max_x = max_x.max(q.x); - max_y = max_y.max(q.y); - } - if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { - return Err(bad(cmd, "point coordinates must be finite")); - } - let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); - let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); - if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { - return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); - } - let area = (w as u64).saturating_mul(h as u64); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - fn placement(phase: (f32, f32), scale: f32, angle: f32) -> Placement { Placement::new(Rect::EMPTY, false, phase, scale, angle) } @@ -188,46 +201,18 @@ fn paint_aligned( sel: Option<&Surface>, lock: bool, tile: &Tile, - phase: (f32, f32), - scale: f32, - angle: f32, + place: Placement, mode: BlendMode, cmd: &str, ) -> Result { - let (bounds, cov) = stroke_coverage(stroke); - reject_bounds(cmd, bounds)?; + let (bounds, cov) = super::coverage(cmd, stroke)?; if bounds.is_empty() { return Ok(Rect::EMPTY); } - let place = placement(phase, scale, angle); - let rgba = render(tile, &place, bounds); - let paint = rgba_to_region(&surf.format(), bounds, &rgba); + let paint = paint_region(tile, &place, &surf.format(), bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) } -#[allow(clippy::too_many_arguments)] -fn paint_impressionist( - surf: &mut Surface, - stroke: &Stroke, - sel: Option<&Surface>, - lock: bool, - tile: &Tile, - pat: &Pattern, - phase: (f32, f32), - scale: f32, - angle: f32, - seed: u64, - mode: BlendMode, -) -> Result { - let fmt = surf.format(); - let tw = pat.width as f32 * scale; - let th = pat.height as f32 * scale; - // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. - Ok(apply_dab_stroke(surf, stroke, sel, lock, 0, |work, fp| { - stamp_dab(work, &fmt, tile, fp, phase, scale, angle, seed, tw, th, mode); - })) -} - fn footprint_cov(fp: &Footprint, x: i32, y: i32) -> f32 { if x < fp.rect.x0 || y < fp.rect.y0 { return 0.0; @@ -303,9 +288,9 @@ mod tests { #[test] fn a_diagonal_across_a_24_mp_canvas_is_not_too_large() { - assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0)).is_ok()); - assert!(reject_bounds("t", Rect::from_xywh(-60, -60, 6136, 4120)).is_ok()); + let across = stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0); + assert!(reject_huge_stroke("t", &across).is_ok()); + assert!(super::super::coverage("t", &across).is_ok(), "the rendered extent fits too"); assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (9000.0, 9000.0)], 100.0)).is_err()); - assert!(reject_bounds("t", Rect::from_xywh(0, 0, 9000, 9000)).is_err()); } } diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 4e256fb7fc..d298e8f02c 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -71,6 +71,7 @@ fn stroke_bounds(stroke: &Stroke) -> Rect { fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option)> { let Some(list) = p.get("strokes") else { let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; return Ok((vec![stroke], id)); }; let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?; @@ -86,6 +87,7 @@ fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option o.insert("points".into(), points.clone()); } let (stroke, layer) = parse_brush(s, &one, CMD)?; + reject_huge_stroke(CMD, &stroke)?; strokes.push(stroke); id = layer; } @@ -115,12 +117,12 @@ fn plan(s: &Session, p: &Value) -> Result { /// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin, /// limited to the selection. Empty when nothing would change. -fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { +fn hole(plan: &Plan, sel: Option<&Surface>) -> Result> { let win = plan.window; let (w, h) = (win.width() as usize, win.height() as usize); let mut m = vec![false; w * h]; for stroke in &plan.strokes { - let (bounds, cov) = stroke_coverage(stroke); + let (bounds, cov) = super::coverage(CMD, stroke)?; let bw = bounds.width() as usize; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); @@ -141,13 +143,13 @@ fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { *v &= c > 0.0; } } - m + Ok(m) } /// The filled window and the hole it fills, computed on `doc` (a snapshot). `None` when the /// layer's own window is fully transparent: nothing to remove and nothing to fill from. fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result)>> { - let hole = hole(plan, doc.selection.as_ref()); + let hole = hole(plan, doc.selection.as_ref())?; if !hole.iter().any(|m| *m) { return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)")); } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e20706857a..7f60b72c16 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,6 +544,96 @@ fn history_brush_restores_earlier_pixels() { } } +/// #4961 and #6110: every retouch stroke renders its coverage densely over the stroke's bounds +/// and reads a source region over the same box, so a stroke whose box dwarfs the canvas is +/// refused from the points and brush size alone, before anything is cloned or rendered. The +/// document is left as it was, and ordinary strokes still paint at every depth. +#[test] +fn history_brush_refuses_a_stroke_too_large_to_render() { + for depth in DEPTHS { + let mut s = session(40, 20, depth, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + + let err = s.execute("paint.historyBrush", json!({"points": [[0, 0], [60000, 60000]], "size": 6, "hardness": 100})).unwrap_err(); + assert!(err.to_string().contains("stroke too large"), "depth {depth}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "depth {depth}: a refused stroke leaves the pixels alone"); + + // The guard is sized for real work: a stroke corner to corner across the canvas still runs. + s.execute("paint.historyBrush", json!({"points": [[0, 0], [40, 20]], "size": 6, "hardness": 100})).unwrap(); + assert!(rgba(&s, 20, 10).iter().all(|v| *v > 0.99), "depth {depth}: the Open snapshot is white"); + } +} + +/// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke without +/// touching the pixels or the history, and an ordinary stroke still succeeds. +#[test] +fn every_retouch_stroke_refuses_a_box_it_cannot_render() { + let huge = json!([[0, 0], [900_000, 900_000]]); + let ok = json!([[2, 2], [30, 16]]); + // Fixtures that are valid for a plain RGB document with one pixel layer. + let cases: [(&str, Value); 4] = [ + ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.spotHealing", json!({"size": 8, "hardness": 100})), + ("paint.historyBrush", json!({"size": 8, "hardness": 100})), + ]; + for (cmd, base) in cases { + let mut s = session(40, 20, 8, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + let steps = s.active().unwrap().history.past_len(); + let with = |points: &Value| { + let mut v = base.clone(); + if let Some(o) = v.as_object_mut() { + o.insert("points".into(), points.clone()); + } + v + }; + let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{cmd}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd}: and records no history step"); + + // The allowance still covers ordinary work: this must succeed, not merely avoid the error. + s.execute(cmd, with(&ok)).unwrap_or_else(|e| panic!("{cmd}: an ordinary stroke must work: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd}: and records one step"); + } + + // Remove takes a list of strokes; each one is charged as it is parsed. + let mut s = session(40, 20, 8, "rgb"); + let err = s.execute("paint.remove", json!({"strokes": [[[0, 0], [900_000, 900_000]]], "size": 8})).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "paint.remove: {err}"); +} + +/// The review on #6110: the estimate from the input points is not the bound. Brush dynamics place +/// dabs beyond those points, so the budget is applied to the extent the renderer actually produced, +/// before the dense buffer is allocated. An injected budget makes that observable without asking +/// for a stroke big enough to exhaust anything. +#[test] +fn the_budget_is_applied_to_the_rendered_extent_not_the_input_points() { + use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; + let mut brush = BrushSettings { size: 24.0, hardness: 1.0, ..Default::default() }; + brush.scattering.enabled = true; + brush.scattering.scatter.jitter = 6.0; + let stroke = Stroke { brush, points: vec![StrokePoint::new(10.0, 10.0, 1.0), StrokePoint::new(40.0, 10.0, 1.0)] }; + + // What the renderer really covered, once scattering has placed every dab. + let (bounds, cov) = super::coverage_within("t", &stroke, u64::MAX).unwrap(); + let rendered = u64::from(bounds.width()) * u64::from(bounds.height()); + assert_eq!(cov.len() as u64, rendered, "the dense buffer is exactly the rendered extent"); + + // Scattering reaches past the points plus half the tip, which is what the estimate alone sees. + let points_only = (40.0 - 10.0 + 24.0 + 8.0) * (24.0 + 8.0); + assert!(rendered as f64 > points_only, "scattering expanded the stroke: {rendered} vs {points_only}"); + + // A budget one pixel under what it rendered refuses it; exactly the rendered extent fits. + let err = super::coverage_within("t", &stroke, rendered - 1).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{err}"); + assert!(super::coverage_within("t", &stroke, rendered).is_ok(), "the rendered extent itself fits"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. @@ -1097,14 +1187,92 @@ fn content_aware_move_preview_shows_the_result_without_touching_the_session() { // The session is untouched. assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31))); assert_eq!(s.active().unwrap().revision, before); - // A cancelled preview stops; a selection past the preview's size and bad params are errors. + // A small selection is solved at full resolution, with nothing to refine. + let job = content_aware_move_preview(&s, &p).unwrap(); + assert!(!job.is_coarse() && job.refine().is_none()); + // A cancelled preview stops; bad params are errors. let ctx = JobCtx::new(); ctx.cancel(); assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled))); assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err()); assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err()); - s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap(); - assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err()); + assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "mode": "copy"})).is_err()); +} + +/// A 30×30 object in the middle of a 400×300 selection (over the 256×256 a preview solves at full +/// resolution) on a 700×400 canvas, dragged by (250, 60). +fn large_move(depth: u64) -> Session { + let mut s = session(700, 400, depth, "rgb"); + paint_layer(&mut s, |x, y| if (195..225).contains(&x) && (145..175).contains(&y) { [0.05, 0.1, 0.9, 1.0] } else { texture(x, y) }); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 400, "height": 300})).unwrap(); + s +} + +#[test] +fn content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths() { + use crate::jobs::JobCtx; + let p = json!({"offset": [250, 60]}); + for depth in DEPTHS { + let mut s = large_move(depth); + let before = s.active().unwrap().revision; + let job = content_aware_move_preview(&s, &p).unwrap(); + assert!(job.is_coarse(), "depth {depth}: solved scaled down"); + let (coarse, damage) = job.run(&JobCtx::new()).unwrap(); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + // The object lands where the command puts it (its middle; the coarse pass softens the + // edges) and its old place is filled. + for (x, y) in [(460, 220), (470, 230), (455, 215), (465, 225)] { + assert!(is_object(px(&coarse, x, y)), "depth {depth}: the object lands at {x},{y}: {:?}", px(&coarse, x, y)); + } + for (x, y) in [(435, 220), (485, 220), (460, 195), (460, 245)] { + assert!(!is_object(px(&coarse, x, y)), "depth {depth}: nothing of the object at {x},{y}"); + } + assert!(!is_object(px(&coarse, 210, 160)), "depth {depth}: the old place is filled"); + assert!(damage.contains(470, 230) && damage.contains(210, 160), "{damage:?}"); + // Outside the selection and where it lands, nothing changes. + for (x, y) in [(5, 5), (690, 20), (20, 390)] { + assert_eq!(px(&coarse, x, y), rgba(&s, x, y), "depth {depth}: {x},{y} untouched"); + } + // The session is untouched. + assert!(is_object(rgba(&s, 210, 160)) && !is_object(rgba(&s, 460, 220))); + assert_eq!(s.active().unwrap().revision, before); + // Cancelled, either pass stops. + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(job.run(&ctx), Err(EngineError::Cancelled)), "depth {depth}"); + let fine = job.refine().unwrap(); + assert!(!fine.is_coarse() && fine.refine().is_none()); + assert!(matches!(fine.run(&ctx), Err(EngineError::Cancelled)), "depth {depth}"); + if depth != 8 { + continue; + } + // The refined preview is the command's result, bit for bit. + let (exact, _) = fine.run(&JobCtx::new()).unwrap(); + assert_eq!(s.active().unwrap().revision, before); + s.execute("paint.contentAwareMove", p.clone()).unwrap(); + let b = exact.bounds(); + let made = s.active().unwrap().doc.layers[0].surface().unwrap().read_region(b); + assert!(exact.layers[0].surface().unwrap().read_region(b) == made, "refined preview = command"); + } +} + +#[test] +fn content_aware_move_coarse_preview_handles_transform_extend_and_limits() { + use crate::jobs::JobCtx; + let s = large_move(8); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + // Transform On Drop: turned 90° about the selection's centre (210, 160), then moved; the + // object sits at the centre, so it lands at (460, 220) either way. + let (turned, _) = content_aware_move_preview(&s, &json!({"offset": [250, 60], "rotate": 90})).unwrap().run(&JobCtx::new()).unwrap(); + assert!(is_object(px(&turned, 460, 220)) && !is_object(px(&turned, 210, 160))); + // Extend keeps the original. + let (extended, _) = content_aware_move_preview(&s, &json!({"offset": [250, 60], "mode": "extend"})).unwrap().run(&JobCtx::new()).unwrap(); + assert!(is_object(px(&extended, 460, 220)) && is_object(px(&extended, 210, 160))); + // Off the canvas, or past the command's own limit, is still an error. + assert!(content_aware_move_preview(&s, &json!({"offset": [400, 0]})).is_err()); + let mut big = session(5000, 4000, 8, "rgb"); + big.execute("select.rect", json!({"x": 0, "y": 0, "width": 4500, "height": 3900})).unwrap(); + assert!(content_aware_move_preview(&big, &json!({"offset": [10, 10]})).is_err()); } #[test] @@ -1557,7 +1725,7 @@ macro_rules! live_coordinate_regression { live_coordinate_regression!( live_retouch_rejects_invalid_batches_without_changing_the_preview, LiveRetouch, - ["paint.cloneStamp", "paint.healingBrush", "paint.historyBrush"] + ["paint.cloneStamp", "paint.healingBrush", "paint.historyBrush", "paint.patternStamp"] ); live_coordinate_regression!( live_dab_rejects_invalid_batches_without_changing_the_preview, @@ -1775,6 +1943,75 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() { } } +/// The live Pattern Stamp shows exactly what the commit paints, fed point by point, at 8 and 16 +/// bits: aligned strokes through their coverage (`LiveRetouch`), Impressionist ones dab by dab +/// (`LiveDab` with float working pixels, as the commit's). +#[test] +fn live_pattern_stamp_matches_the_commit() { + let all = [[20.0, 30.0], [34.0, 36.0], [50.0, 28.0], [70.0, 34.0]]; + for depth in [8, 16] { + for (impressionist, aligned) in [(false, true), (false, false), (true, true)] { + let mut s = session(96, 64, depth, "rgb"); + paint_layer(&mut s, texture); + let opts = |pts: &[[f64; 2]]| { + json!({"points": pts, "size": 14, "hardness": 40, "opacity": 70, "pattern": "Checkerboard", "scale": 150, "angle": 20, + "aligned": aligned, "impressionist": impressionist, "mode": "multiply", "seed": 11}) + }; + let (doc, h) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.past_len()); + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + let shown = if impressionist { + assert!(LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).is_err(), "Impressionist is laid dab by dab"); + let mut live = live_pattern_impressionist(&s, &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + live.doc.clone() + } else { + assert!(live_pattern_impressionist(&s, &opts(&all[..1])).is_err()); + let mut live = LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + live.doc.clone() + }; + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc), "the preview leaves the document alone"); + s.execute("paint.patternStamp", opts(&all)).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), h + 1, "one History step"); + let id = s.active().unwrap().active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), s.active().unwrap().doc.layer(id).unwrap().surface().unwrap()); + let mut changed = false; + for y in 0..64 { + for x in 0..96 { + let (got, want) = (a.rgba(x, y), b.rgba(x, y)); + changed |= (0..3).any(|c| (want[c] - texture(x, y)[c]).abs() > 0.02); + assert_eq!(got, want, "impressionist {impressionist} aligned {aligned} {depth}-bit ({x}, {y})"); + } + } + assert!(changed, "the stroke painted"); + } + } +} + +/// A live Pattern Stamp on the layer mask paints the mask, as the commit does, and leaves the +/// layer's pixels alone. +#[test] +fn live_pattern_stamp_paints_the_targeted_mask() { + let mut s = session(64, 48, 8, "rgb"); + paint_layer(&mut s, texture); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let all = [[16.0, 20.0], [40.0, 24.0]]; + let opts = |pts: &[[f64; 2]]| json!({"points": pts, "size": 12, "pattern": "Checkerboard", "target": "mask", "seed": 3}); + let mut live = LiveRetouch::begin(&s, "paint.patternStamp", &opts(&all[..1])).unwrap(); + live.push(&[StrokePoint::new(40.0, 24.0, 1.0)]).unwrap(); + let shown = live.doc.clone(); + s.execute("paint.patternStamp", opts(&all)).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let (lv, cm) = (shown.layer(id).unwrap(), s.active().unwrap().doc.layer(id).unwrap()); + let (lm, cmask) = (&lv.mask.as_ref().unwrap().surface, &cm.mask.as_ref().unwrap().surface); + let b = Rect::new(0, 0, 64, 48); + assert_eq!(lm.read_region(b), cmask.read_region(b), "the live mask is the committed mask"); + assert!((0..64).any(|x| cmask.rgba(x, 22)[0] < 0.99), "the mask was painted"); + assert_eq!(lv.surface().unwrap().read_region(b), cm.surface().unwrap().read_region(b), "pixels untouched"); +} + #[test] fn remove_several_strokes_apply_as_one_job_and_one_undo_step() { let mut s = square_session(8, "rgb"); diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 472ecb672a..a67b023a6f 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -5,7 +5,7 @@ //! document, are discarded when it closes, and are not saved to `.pcraft` or PSD. Placing, moving //! and removing them changes no pixels and adds no History step, so they are not journaled. Each //! point reads one composite pixel (no averaging); its values match the Info panel's pointer -//! readout (RGB 0–255 and the naive device CMYK percentages, without an ICC profile). +//! readout (RGB 0–255 and colour-managed CMYK percentages, as in the Color Picker). use photocraft_doc::Document; use serde_json::{Value, json}; @@ -24,22 +24,23 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. 32-bit samples can -/// lie outside 0..1, so each channel is clamped first (NaN reads as 0), like the RGB readout. +/// Colour-managed CMYK, in percent, as the Info panel shows it: the same conversion the Color +/// Picker uses (`photocraft_color::convert::rgb_to_cmyk`: the CMYK profile active on this thread, +/// else the built-in coated CMYK), so both panels agree (#8101). 32-bit samples can lie outside +/// 0..1, so each channel is clamped first (NaN reads as 0), like the RGB readout. pub fn device_cmyk(r: f32, g: f32, b: f32) -> [i32; 4] { let unit = |v: f32| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; - let (r, g, b) = (unit(r), unit(g), unit(b)); - let k = 1.0 - r.max(g).max(b); - let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; - [f(r), f(g), f(b), k].map(|v| (v * 100.0).round() as i32) + photocraft_color::convert::rgb_to_cmyk([unit(r), unit(g), unit(b)]).map(|v| (unit(v) * 100.0).round() as i32) } /// The Info-panel values for a sample point at document pixel `pos`: composite RGB 0–255 and -/// naive device CMYK percentages. One composite pixel, no averaging. +/// colour-managed CMYK percentages (the document's CMYK profile when it has one). One composite +/// pixel, no averaging. pub fn readout(doc: &Document, pos: [f64; 2]) -> ([i32; 3], [i32; 4]) { let rgba = crate::sample_cmds::sample_color(doc, None, pos[0], pos[1], 1, crate::sample_cmds::SampleLayers::All); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32; - ([q(rgba[0]), q(rgba[1]), q(rgba[2])], device_cmyk(rgba[0], rgba[1], rgba[2])) + let cmyk = photocraft_color::convert::with_cmyk_space(photocraft_compose::cmyk_space(doc).as_ref(), || device_cmyk(rgba[0], rgba[1], rgba[2])); + ([q(rgba[0]), q(rgba[1]), q(rgba[2])], cmyk) } /// One point as the list command reports it. @@ -257,7 +258,7 @@ mod tests { .unwrap(); let r = s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); assert_eq!(r["point"]["rgb"], json!([0, 0, 0])); - assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 100])); + assert_eq!(r["point"]["cmyk"], json!(device_cmyk(0.0, 0.0, 0.0)), "the profile's black, not the naive 0/0/0/100"); // Painting over the point changes its readout (the list is computed live). s.edit("paint", |doc, _| { let surf = doc.layers[0].surface_mut().unwrap(); @@ -365,7 +366,7 @@ mod tests { #[test] fn a_right_angle_rotation_moves_points() { - // 90° clockwise about the centre: the doc centre stays put. + // 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x). let mut s = session(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); @@ -373,19 +374,97 @@ mod tests { assert_eq!(p.len(), 1); let pos = p[0]["position"].as_array().unwrap(); let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap()); - // The new canvas is 80×100, so the centre is (40, 50). - assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + assert!((x - 39.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + } + + #[test] + fn flips_and_right_angle_turns_keep_points_on_their_pixel() { + use crate::canvas_geom::Turn; + for (id, turn) in [ + ("image.imageRotation.flipCanvasHorizontal", Turn::FlipHorizontal), + ("image.imageRotation.flipCanvasVertical", Turn::FlipVertical), + ("image.imageRotation.180", Turn::Rotate180), + ("image.imageRotation.90cw", Turn::Cw90), + ("image.imageRotation.90ccw", Turn::Ccw90), + ] { + // A black pixel at (3, 3) of a white 100×80 document, with a point on it. + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute(id, json!({})).unwrap(); + let (x, y) = turn.pixel_map(100, 80)(3, 3); + let p = points(&mut s); + assert_eq!(p.len(), 1, "{id}"); + assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}"); + } + } + + #[test] + fn an_arbitrary_rotation_keeps_points_on_their_pixel() { + // The pixel at (3, 3) of the 100×80 document, and where each turn moves it. + for (angle, direction, at) in [(90, "cw", [76.0, 3.0]), (90, "ccw", [3.0, 96.0]), (180, "cw", [96.0, 76.0])] { + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 0, "y": 0})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": angle, "direction": direction})).unwrap(); + assert_eq!(s.take_samplers_removed(), 0, "{angle} {direction}: the corner point stays on the canvas"); + let p = points(&mut s); + assert_eq!(p.len(), 2, "{angle} {direction}: the corner point stays on the canvas"); + assert_eq!(p[0]["position"], json!(at), "{angle} {direction}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{angle} {direction}"); + } } #[test] fn a_rotated_crop_moves_points() { - // The crop frame is centred on the point, so the point lands at the new canvas centre. + // The crop frame is centred on the point's pixel; turned 90° that pixel lands at (10, 9). let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(50, 40, 51, 41), &black); + Ok(()) + }) + .unwrap(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap(); let p = points(&mut s); assert_eq!(p.len(), 1); - assert_eq!(p[0]["position"], json!([10.0, 10.0])); + assert_eq!(p[0]["position"], json!([10.0, 9.0])); + assert_eq!(p[0]["rgb"], json!([0, 0, 0])); + } + + #[test] + fn a_crop_to_a_target_size_scales_points() { + // The crop resamples the 20 x 20 area to 40 x 40: the point (10, 10 after the shift; 10, 9 + // turned 90°, as in `a_rotated_crop_moves_points`) doubles. + for (angle, at) in [(None, [20.0, 20.0]), (Some(90), [20.0, 18.0])] { + let mut s = session(); + s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); + let mut p = json!({"x": 40, "y": 30, "width": 20, "height": 20, "targetWidth": 40, "targetHeight": 40}); + if let Some(a) = angle { + p["angle"] = json!(a); + } + s.execute("image.crop", p).unwrap(); + let p = points(&mut s); + assert_eq!(p.len(), 1, "angle {angle:?}"); + assert_eq!(p[0]["position"], json!(at), "angle {angle:?}"); + assert_eq!(s.take_samplers_removed(), 0, "angle {angle:?}"); + } } #[test] @@ -413,12 +492,16 @@ mod tests { assert!(points(&mut s).is_empty()); } + /// The Info panel and the Color Picker read CMYK through one conversion (#8101): the + /// colour-managed `rgb_to_cmyk`, not a naive formula that disagrees with it by tens of percent. #[test] - fn device_cmyk_matches_the_info_panel_formula() { + fn device_cmyk_agrees_with_the_color_picker_conversion() { + for rgb in [[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.2, 0.6, 0.9], [1.0, 0.93, 0.0]] { + let picker = photocraft_color::convert::rgb_to_cmyk(rgb).map(|v| (v * 100.0).round() as i32); + assert_eq!(device_cmyk(rgb[0], rgb[1], rgb[2]), picker, "{rgb:?}"); + } assert_eq!(device_cmyk(1.0, 1.0, 1.0), [0, 0, 0, 0]); - assert_eq!(device_cmyk(0.0, 0.0, 0.0), [0, 0, 0, 100]); - assert_eq!(device_cmyk(1.0, 0.0, 0.0), [0, 100, 100, 0]); - assert_eq!(device_cmyk(0.5, 0.5, 0.5), [0, 0, 0, 50]); + assert_ne!(device_cmyk(1.0, 0.0, 0.0), [0, 100, 100, 0], "pure red is not the naive 0/100/100/0"); } /// 32-bit samples can lie outside 0..1 (or be NaN); the CMYK readout clamps them like the RGB @@ -426,13 +509,13 @@ mod tests { #[test] fn device_cmyk_clamps_out_of_range_and_nan_samples() { assert_eq!(device_cmyk(1.5, 1.5, 1.5), [0, 0, 0, 0]); - assert_eq!(device_cmyk(2.0, 0.0, 0.0), [0, 100, 100, 0]); + assert_eq!(device_cmyk(2.0, 0.0, 0.0), device_cmyk(1.0, 0.0, 0.0)); let in_range = |c: [i32; 4]| c.iter().all(|v| (0..=100).contains(v)); for (r, g, b) in [(-0.2, 0.5, 0.5), (-1.0, -1.0, -1.0), (f32::NAN, 0.5, 0.5), (f32::NAN, f32::NAN, f32::NAN), (f32::INFINITY, f32::NEG_INFINITY, 0.5)] { let c = device_cmyk(r, g, b); assert!(in_range(c), "{r} {g} {b} -> {c:?}"); } - assert_eq!(device_cmyk(f32::NAN, f32::NAN, f32::NAN), [0, 0, 0, 100]); + assert_eq!(device_cmyk(f32::NAN, f32::NAN, f32::NAN), device_cmyk(0.0, 0.0, 0.0)); } #[test] diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index 5feae1a75f..b3fae926eb 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -100,12 +100,23 @@ pub fn isolation_shows(doc: &Document, isolated: &[LayerId], id: LayerId) -> boo // ---------------------------------------------------------------- Transform Selection -fn reference_point(p: &Value, r: [f64; 4]) -> [f64; 2] { +/// The point the scale / rotate / skew params pivot about: a named point of the frame `r` or +/// `[x, y]`; missing (or null) is the centre. Anything else is an error, not a silent centre. +fn reference_point(cmd: &str, p: &Value, r: [f64; 4]) -> Result<[f64; 2]> { let (x0, y0, x1, y1) = (r[0], r[1], r[2], r[3]); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - match p.get("reference") { - Some(Value::Array(a)) if a.len() >= 2 => [a[0].as_f64().unwrap_or(cx), a[1].as_f64().unwrap_or(cy)], + let unknown = || bad(cmd, "`reference` must be one of center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight or [x, y]"); + Ok(match p.get("reference") { + None | Some(Value::Null) => [cx, cy], + Some(Value::Array(a)) => match a.as_slice() { + [x, y] => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => [x, y], + _ => return Err(unknown()), + }, + _ => return Err(unknown()), + }, Some(Value::String(k)) => match k.as_str() { + "center" => [cx, cy], "topLeft" => [x0, y0], "top" => [cx, y0], "topRight" => [x1, y0], @@ -114,10 +125,10 @@ fn reference_point(p: &Value, r: [f64; 4]) -> [f64; 2] { "bottomLeft" => [x0, y1], "bottom" => [cx, y1], "bottomRight" => [x1, y1], - _ => [cx, cy], + _ => return Err(unknown()), }, - _ => [cx, cy], - } + Some(_) => return Err(unknown()), + }) } fn quad(p: &Value) -> Option<[[f64; 2]; 4]> { @@ -134,8 +145,8 @@ fn quad(p: &Value) -> Option<[[f64; 2]; 4]> { } /// The homography of the scale / rotate / skew / move params about the reference point. -fn affine_params(p: &Value, r: [f64; 4]) -> Homography { - let [px, py] = reference_point(p, r); +fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result { + let [px, py] = reference_point(cmd, p, r)?; let (sx, sy) = (f(p, "scaleX", 100.0) / 100.0, f(p, "scaleY", 100.0) / 100.0); let a = f(p, "rotate", 0.0).to_radians(); let (kx, ky) = (f(p, "skewX", 0.0).to_radians().tan(), f(p, "skewY", 0.0).to_radians().tan()); @@ -145,9 +156,11 @@ fn affine_params(p: &Value, r: [f64; 4]) -> Homography { let rot = Homography([ca, -sa, 0.0, sa, ca, 0.0, 0.0, 0.0, 1.0]); let skew = Homography([1.0, kx, 0.0, ky, 1.0, 0.0, 0.0, 0.0, 1.0]); let scale = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); - t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py)) + Ok(t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py))) } +/// Select › Transform Selection: warps the selection outline by the params' matrix, quad or +/// scale/rotate/move, refused when the result would not fit the transform budget. fn transform_selection(s: &mut Session, p: &Value) -> Result { const CMD: &str = "select.transformSelection"; let d = s.active().ok_or(EngineError::NoDocument)?; @@ -185,7 +198,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0]) } else { - affine_params(p, rect) + affine_params(CMD, p, rect)? }; if h.inverse().is_none() { return Err(bad(CMD, "the transform collapses the selection")); @@ -195,7 +208,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } let selected = s.edit("Transform Selection", |doc, _| { if let Some(sel) = &doc.selection { - crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?; + crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?; doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); } Ok(doc.selection.is_some()) @@ -357,6 +370,25 @@ mod tests { assert_eq!(sel_bounds(&s), Some(Rect::new(50, 20, 70, 30))); } + /// A misspelt or malformed `reference` is an error, not a silent scale about the centre. + #[test] + fn transform_selection_rejects_an_unknown_reference() { + let mut s = rect_sel(); + for r in [json!("topleft"), json!("top-left"), json!(["a", 0]), json!([1]), json!([1, 2, 3]), json!(7)] { + assert!(s.execute("select.transformSelection", json!({"scaleX": 50, "reference": r})).is_err(), "{r}"); + assert_eq!(sel_bounds(&s), Some(Rect::new(20, 30, 40, 40)), "{r}"); + } + s.execute("select.transformSelection", json!({"scaleX": 50, "reference": "center"})).unwrap(); + let centre = sel_bounds(&s); + s.undo(); + s.execute("select.transformSelection", json!({"scaleX": 50})).unwrap(); + assert_eq!(sel_bounds(&s), centre); + s.undo(); + s.execute("select.transformSelection", json!({"scaleX": 50, "reference": [20, 30]})).unwrap(); + let b = sel_bounds(&s).unwrap(); + assert!(b.x0.abs_diff(20) <= 1 && b.x1.abs_diff(30) <= 1, "{b:?}"); + } + #[test] fn transform_selection_perspective_and_warp() { let mut s = rect_sel(); diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 2bd765673f..33383d1045 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -67,20 +67,20 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), - ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), - ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), - ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), - ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), - ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), - ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), - ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), - ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), - ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), - ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), - ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), - ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), + ("window.theme.pro", "Pro", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.proMedium", "Pro Medium Gray", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.studio", "Studio", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.solarizedDark", "Solarized", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.adwaitaDark", "Adwaita", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.breezeDark", "Breeze", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.softDark", "Soft", &["Window", "Theme", "Dark Theme"], None), + ("window.theme.proLightGray", "Pro Light Gray", &["Window", "Theme", "Light Theme"], None), + ("window.theme.proLight", "Pro Light", &["Window", "Theme", "Light Theme"], None), + ("window.theme.studioLight", "Studio", &["Window", "Theme", "Light Theme"], None), + ("window.theme.classic", "Classic", &["Window", "Theme", "Light Theme"], None), + ("window.theme.adwaita", "Adwaita", &["Window", "Theme", "Light Theme"], None), + ("window.theme.breezeLight", "Breeze", &["Window", "Theme", "Light Theme"], None), + ("window.theme.softLight", "Soft", &["Window", "Theme", "Light Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs index 26062def96..4b36de1b82 100644 --- a/crates/engine/src/shortcut_sets.rs +++ b/crates/engine/src/shortcut_sets.rs @@ -35,15 +35,31 @@ pub struct ShortcutSet { } impl ShortcutSet { - /// The bindings `p` uses now. - pub fn of(p: &Preferences) -> Self { - Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() } + /// A set of these bindings, without the actions' function keys (they belong to the + /// actions, as in an `.atn` file, not to a set). + pub fn from_bindings(shortcuts: &BTreeMap, alternates: &BTreeMap>) -> Self { + let shortcuts = shortcuts.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone())).collect(); + let alternate_shortcuts = alternates.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone())).collect(); + Self { shortcuts, alternate_shortcuts } } - /// Use this set's bindings in `p`. + /// The bindings `p` uses now, without the actions' function keys. + pub fn of(p: &Preferences) -> Self { + Self::from_bindings(&p.shortcuts, &p.alternate_shortcuts) + } + + /// Put this set's bindings in place of `shortcuts` / `alternates`, keeping the actions' + /// function keys: switching sets with `useSet` and in the Keyboard Shortcuts dialog. + pub fn apply(&self, shortcuts: &mut BTreeMap, alternates: &mut BTreeMap>) { + shortcuts.retain(|id, _| is_action_key(id)); + shortcuts.extend(self.shortcuts.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone()))); + alternates.retain(|id, _| is_action_key(id)); + alternates.extend(self.alternate_shortcuts.iter().filter(|(id, _)| !is_action_key(id)).map(|(id, k)| (id.clone(), k.clone()))); + } + + /// Use this set's bindings in `p`, keeping the actions' function keys. pub fn load_into(&self, p: &mut Preferences) { - p.shortcuts = self.shortcuts.clone(); - p.alternate_shortcuts = self.alternate_shortcuts.clone(); + self.apply(&mut p.shortcuts, &mut p.alternate_shortcuts); } /// Check every key (a set from a call or a hand-edited file must not hold garbage). @@ -60,6 +76,11 @@ impl ShortcutSet { } } +/// An action's function key (`actions.play:`): kept by the action, not by a set. +fn is_action_key(id: &str) -> bool { + id.starts_with(crate::actions_cmds::PLAY_SHORTCUT_PREFIX) +} + /// A set name as given, trimmed; an error for an empty, overlong or reserved name. pub fn set_name(name: &str) -> Result { let n = name.trim(); @@ -83,15 +104,22 @@ fn saved(p: &Preferences) -> Option<&ShortcutSet> { p.shortcut_sets.get(&p.shortcut_set) } -/// Do the bindings differ from the set in use? -pub fn modified(p: &Preferences) -> bool { - let now = ShortcutSet::of(p); - match saved(p) { - Some(s) => *s != now, +/// Do these bindings differ from the set `active` of `sets` ("" or a missing name: the +/// defaults)? The actions' function keys don't count, on either side (a set saved before they +/// were left out may still hold some). The Keyboard Shortcuts dialog's "(modified)" too. +pub fn differs(sets: &BTreeMap, active: &str, shortcuts: &BTreeMap, alternates: &BTreeMap>) -> bool { + let now = ShortcutSet::from_bindings(shortcuts, alternates); + match sets.get(active) { + Some(s) => ShortcutSet::from_bindings(&s.shortcuts, &s.alternate_shortcuts) != now, None => now != ShortcutSet::default(), } } +/// Do the bindings differ from the set in use? +pub fn modified(p: &Preferences) -> bool { + differs(&p.shortcut_sets, &p.shortcut_set, &p.shortcuts, &p.alternate_shortcuts) +} + /// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the /// defaults) and makes it the one in use. Returns whether it changed anything. pub fn load(next: &mut Preferences, p: &Value) -> Result { @@ -123,7 +151,8 @@ pub fn store(next: &mut Preferences, p: &Value) -> Result { let name = set_name(name)?; let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?; set.validate().map_err(|e| format!("set `{name}`: {e}"))?; - sets.insert(name, set); + // Like `saveSet`, a set holds no action keys. + sets.insert(name, ShortcutSet::from_bindings(&set.shortcuts, &set.alternate_shortcuts)); } next.shortcut_sets = sets; changed = true; @@ -292,6 +321,57 @@ mod tests { assert!(modified(&p), "F9 differs from the defaults"); } + #[test] + fn sets_from_the_dialog_or_an_old_file_hold_no_action_keys() { + // The dialog's OK sends its sets as `sets`; an action key in one is dropped. + let mut p = prefs(); + let sets = json!({"Mine": {"shortcuts": {"edit.copy": "F9", "actions.play:Sepia": "F2"}}}); + store(&mut p, &json!({"sets": sets, "activeSet": "Mine"})).unwrap(); + assert_eq!(p.shortcut_sets["Mine"].shortcuts, BTreeMap::from([("edit.copy".to_string(), "F9".to_string())])); + // A set saved before action keys were left out still holds one: it doesn't count. + p.shortcut_sets.get_mut("Mine").unwrap().shortcuts.insert("actions.play:Old".into(), "F5".into()); + p.shortcuts = BTreeMap::from([("edit.copy".to_string(), "F9".to_string()), ("actions.play:Sepia".to_string(), "F2".to_string())]); + assert!(!modified(&p), "neither side's action keys count"); + p.shortcuts.insert("edit.copy".into(), "F8".into()); + assert!(modified(&p), "a real edit does"); + } + + #[test] + fn switching_sets_keeps_action_function_keys() { + // An action's F-key belongs to the action (as in an .atn file), not to a shortcut set. + let mut p = prefs(); + store(&mut p, &json!({"saveSet": "Mine"})).unwrap(); + p.shortcuts.insert("actions.play:Sepia".into(), "F2".into()); + assert!(!modified(&p), "an action key doesn't modify the set"); + load(&mut p, &json!({"useSet": ""})).unwrap(); + assert_eq!(p.shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"), "kept switching to the defaults"); + load(&mut p, &json!({"useSet": "Mine"})).unwrap(); + assert_eq!(p.shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"), "kept switching to a named set"); + store(&mut p, &json!({"saveSet": "Other"})).unwrap(); + assert!(p.shortcut_sets.values().all(|s| s.shortcuts.is_empty()), "a saved set holds no action keys"); + } + + #[test] + fn switching_sets_keeps_action_alternate_keys() { + // An action key set as an alternate (`alternateShortcuts.actions.play:`) is the + // action's too: a saved set leaves it out and switching sets keeps it. + let mut p = prefs(); + p.alternate_shortcuts.insert("edit.copy".into(), vec!["F9".into()]); + p.alternate_shortcuts.insert("actions.play:Sepia".into(), vec!["F7".into()]); + store(&mut p, &json!({"saveSet": "Mine"})).unwrap(); + assert_eq!(p.shortcut_sets["Mine"].alternate_shortcuts, BTreeMap::from([("edit.copy".to_string(), vec!["F9".to_string()])])); + load(&mut p, &json!({"useSet": ""})).unwrap(); + assert_eq!(p.alternate_shortcuts, BTreeMap::from([("actions.play:Sepia".to_string(), vec!["F7".to_string()])]), "kept switching to the defaults"); + let sets = json!({"Other": {"alternateShortcuts": {"edit.cut": ["F8"], "actions.play:Old": ["F6"]}}}); + store(&mut p, &json!({"sets": sets})).unwrap(); + load(&mut p, &json!({"useSet": "Other"})).unwrap(); + assert_eq!( + p.alternate_shortcuts, + BTreeMap::from([("actions.play:Sepia".to_string(), vec!["F7".to_string()]), ("edit.cut".to_string(), vec!["F8".to_string()])]), + "a set from the dialog brings no action key and keeps the one in use" + ); + } + #[test] fn bad_names_sets_and_params_are_errors() { let mut p = prefs(); diff --git a/crates/engine/src/slice_cmds.rs b/crates/engine/src/slice_cmds.rs index 959f132927..0cdb4a95e3 100644 --- a/crates/engine/src/slice_cmds.rs +++ b/crates/engine/src/slice_cmds.rs @@ -243,7 +243,8 @@ pub fn resolved_json(doc: &Document, r: &ResolvedSlice) -> Value { "number": r.number, "id": r.id, "origin": r.origin.id(), - "name": r.name, + // A covered slice's later pieces show as `-NN`; the slice itself keeps its name. + "name": s.map(|s| s.name.as_str()).filter(|n| !n.is_empty()).unwrap_or(&r.name), "rect": [r.rect.x0, r.rect.y0, r.rect.width(), r.rect.height()], "kind": r.kind.id(), "layer": s.and_then(|s| s.layer).map(|l| l.0), @@ -256,6 +257,30 @@ pub fn resolved_json(doc: &Document, r: &ResolvedSlice) -> Value { }) } +/// A stored slice as `slice.list` and `slice.set` report it: once, with its own (canvas-clipped) +/// rectangle, the one `slice.set` takes back, and the visible `pieces` a later slice cuts it into, +/// which are numbered and exported separately (#7770). `number` is its first piece's, `null` when +/// later slices cover it wholly. `None` for an unknown id. +fn stored_json(doc: &Document, id: u32, resolved: &[ResolvedSlice]) -> Option { + let sl = doc.slices.get(id)?; + let pieces: Vec<&ResolvedSlice> = resolved.iter().filter(|r| r.id == Some(id)).collect(); + let first = pieces.first(); + let whole = ResolvedSlice { + number: first.map_or(0, |r| r.number), + id: Some(id), + origin: sl.origin, + rect: sl.rect.intersect(&doc.bounds()), + name: first.map_or_else(|| sl.name.clone(), |r| r.name.clone()), + kind: sl.kind, + }; + let mut v = resolved_json(doc, &whole); + if first.is_none() { + v["number"] = Value::Null; + } + v["pieces"] = pieces.iter().map(|r| json!({"number": r.number, "name": r.name, "rect": [r.rect.x0, r.rect.y0, r.rect.width(), r.rect.height()]})).collect(); + Some(v) +} + // ---------- commands ---------- fn new_slice(s: &mut Session, p: &Value) -> Result { @@ -321,16 +346,16 @@ fn set_slice(s: &mut Session, p: &Value) -> Result { // nothing is committed unless every option is valid and the slice stays on the canvas. let id = s.edit("Slice Options", |doc, _| { let id = store(doc, t)?; + let canvas = doc.bounds(); let sl = doc.slices.get_mut(id).ok_or_else(|| bad(cmd, "slice vanished"))?; apply_options(sl, p, cmd)?; - if !slices::resolve(doc).iter().any(|r| r.id == Some(id)) { + if sl.rect.intersect(&canvas).is_empty() { return Err(bad(cmd, "slice is off the canvas")); } Ok(id) })?; let d = s.active().ok_or(EngineError::NoDocument)?; - let r = slices::resolve(&d.doc).into_iter().find(|r| r.id == Some(id)).ok_or_else(|| bad(cmd, "slice is off the canvas"))?; - Ok(resolved_json(&d.doc, &r)) + stored_json(&d.doc, id, &slices::resolve(&d.doc)).ok_or_else(|| bad(cmd, "slice vanished")) } fn promote(s: &mut Session, p: &Value) -> Result { @@ -444,7 +469,22 @@ fn divide(s: &mut Session, p: &Value) -> Result { fn list(s: &mut Session) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; - let all: Vec = slices::resolve(&d.doc).iter().map(|r| resolved_json(&d.doc, r)).collect(); + // One entry per slice, in reading order of its first piece: auto slices as they resolve, a + // stored slice once with its pieces; slices wholly covered by later ones follow. + let resolved = slices::resolve(&d.doc); + let mut listed = std::collections::HashSet::new(); + let mut all: Vec = Vec::new(); + for r in &resolved { + match r.id { + None => all.push(resolved_json(&d.doc, r)), + Some(id) if listed.insert(id) => all.extend(stored_json(&d.doc, id, &resolved)), + Some(_) => {} + } + } + let canvas = d.doc.bounds(); + for sl in d.doc.slices.list.iter().filter(|sl| !listed.contains(&sl.id) && !sl.rect.intersect(&canvas).is_empty()) { + all.extend(stored_json(&d.doc, sl.id, &resolved)); + } Ok(json!({"slices": all, "locked": s.file_menu.slices_locked})) } @@ -534,7 +574,7 @@ pub fn specs() -> Vec { label: "List Slices", menu: &[], shortcut: None, - params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…}], locked}", + params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…,pieces?:[{number,name,rect}] (a stored slice's visible parts; number null when wholly covered)}], locked}", enabled: has_doc, journal: false, run: |s, _| list(s), diff --git a/crates/engine/src/slice_cmds/tests.rs b/crates/engine/src/slice_cmds/tests.rs index 4c3008ef41..65e380d1ca 100644 --- a/crates/engine/src/slice_cmds/tests.rs +++ b/crates/engine/src/slice_cmds/tests.rs @@ -51,6 +51,39 @@ fn user_slices_and_options() { } } +#[test] +fn covered_slices_keep_their_options() { + // #7770: a slice under a later one shows as its visible pieces, yet stays one slice. + let mut s = session(8); + let under = s.execute("slice.new", json!({"rect": [0, 0, 60, 60], "name": "under"})).unwrap()["slice"].as_u64().unwrap(); + s.execute("slice.new", json!({"rect": [40, 40, 60, 50], "name": "over"})).unwrap(); + let listed = |s: &mut Session| -> Vec { + let l = s.execute("slice.list", json!({})).unwrap(); + l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).cloned().collect() + }; + // Listed once, with its own rectangle and name; the visible pieces are numbered separately. + let entries = listed(&mut s); + assert_eq!(entries.len(), 1, "{entries:?}"); + let e = &entries[0]; + assert_eq!((&e["name"], &e["rect"], &e["number"]), (&json!("under"), &json!([0, 0, 60, 60]), &json!(1))); + let pieces = e["pieces"].as_array().unwrap(); + assert_eq!(pieces.len(), 2, "{pieces:?}"); + assert_eq!(pieces[0], json!({"number": 1, "name": "under", "rect": [0, 0, 60, 40]})); + assert_eq!(pieces[1]["rect"], json!([0, 40, 40, 20])); + // Its listed rectangle written back through Slice Options keeps the slice whole. + s.execute("slice.set", json!({"slice": under, "rect": e["rect"]})).unwrap(); + assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(0, 0, 60, 60)); + // Moved wholly under the later slice, it is still on the canvas, and still listed. + let r = s.execute("slice.set", json!({"slice": under, "rect": [50, 50, 10, 10], "alt": "hidden"})).unwrap(); + assert_eq!(r["number"], Value::Null); + assert_eq!(r["alt"], "hidden"); + assert_eq!(r["pieces"], json!([])); + assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(50, 50, 60, 60)); + let entries = listed(&mut s); + assert_eq!(entries.len(), 1, "a wholly covered slice is still listed: {entries:?}"); + assert_eq!((&entries[0]["number"], &entries[0]["rect"]), (&Value::Null, &json!([50, 50, 10, 10]))); +} + #[test] fn rect_param_rejects_corner_overflow() { assert_eq!(rect_param(&json!({"rect": [1, 2, 3, 4]})), Some(Rect::new(1, 2, 4, 6))); @@ -276,7 +309,9 @@ fn rejected_slice_options_change_nothing() { // A user slice moved off the canvas is rejected without committing the move. let id = s.execute("slice.new", json!({"rect": [0, 0, 10, 10]})).unwrap()["slice"].as_u64().unwrap() as u32; let past = s.active().unwrap().history.past_len(); - assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10]})).is_err()); + let before = doc(&s).slices.clone(); + assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10], "alt": "gone"})).is_err()); + assert_eq!(doc(&s).slices, before, "neither the move nor the option is committed"); assert_eq!(doc(&s).slices.get(id).unwrap().rect, Rect::new(0, 0, 10, 10)); assert_eq!(s.active().unwrap().history.past_len(), past); } diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..fdd69e4568 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex}; use photocraft_algo::resample::translate_surface; use photocraft_algo::transform::Homography; -use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartContentsId, SmartFilter, SmartObject, SmartSource}; use photocraft_geom::{Affine, Rect, Size}; use photocraft_raster::Surface; @@ -77,8 +77,13 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() } +/// A linked file's bytes; `None` when it is missing or its path is not followed +/// ([`crate::doc_path::resolve`]). fn read_file(path: &str) -> Option> { - if path.is_empty() { None } else { photocraft_format::read_file(std::path::Path::new(path)).ok() } + if path.is_empty() { + return None; + } + photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok() } /// The source file of a smart object: embedded bytes, the PSD's embedded linked-layer data (PSD @@ -189,6 +194,30 @@ fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surf s } +fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { + let gray = fmt.mode == ColorMode::Grayscale && src.pixel_format().mode != ColorMode::Grayscale; + let linear = fmt.sample == SampleType::F32 && src.depth != SampleType::F32; + if !gray && !linear { + return Ok(buffer_to_surface(buf, fmt)); + } + let mut doc = Document::new(src.name.clone(), src.size, src.mode, src.depth); + doc.icc_profile = src.icc_profile.clone(); + doc.layers = vec![Layer::new("source", LayerContent::Raster(buffer_to_surface(buf, src.pixel_format())))]; + if gray { + let gray_profile = crate::color_cmds::working_profile(ColorMode::Grayscale); + crate::color_cmds::convert_document(&mut doc, &gray_profile, photocraft_cms::Intent::RelativeColorimetric, true)?; + } + if linear { + crate::image_cmds::change_depth(&mut doc, SampleType::F32); + } + let surface = doc.layers.pop().and_then(|l| match l.content { + LayerContent::Raster(s) => Some(s), + _ => None, + }); + let surface = surface.ok_or_else(|| other("the smart object's source could not be converted"))?; + Ok(if surface.format() == fmt { surface } else { surface.convert(fmt) }) +} + /// An SVG source rendered at `scale` times its own size (cached per scale), so a vector smart /// object stays sharp however it is placed. pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result { @@ -224,7 +253,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result Stat::Variance, }; let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) }; - let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds }; + let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds }; cache_put(key, img.clone()); Ok(img) } // ---------- rendering ---------- -/// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options). +/// Mix a filter's result with its input, without stacking the same pixel coverage twice. fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface { + if opacity <= 0.0 { + return base.clone(); + } let fmt = base.format(); let area = base.content_bounds().union(&top.content_bounds()); let mut out = base.clone(); @@ -288,9 +320,38 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) } let n = fmt.channels(); let (b, t) = (base.read_region(area), top.read_region(area)); + // Alpha-preserving filters edit the same coverage. A non-Normal filter that + // changes coverage (e.g. blur) must also retain input-only pixels and admit + // filtered-only pixels, as Photoshop's Smart Filter blend oracles require. + // Classify the whole result so unchanged pixels do not switch blend rules + // independently at the edge of a blur. + let composite_coverage = mode != BlendMode::Normal && fmt.alpha && b.chunks_exact(n).zip(t.chunks_exact(n)).any(|(bp, tp)| bp.last() != tp.last()); let mut o = vec![0.0f32; b.len()]; for ((bp, tp), op) in b.chunks_exact(n).zip(t.chunks_exact(n)).zip(o.chunks_exact_mut(n)) { - let rgba = photocraft_color::blend::composite(mode, photocraft_raster::to_rgba(&fmt, bp), photocraft_raster::to_rgba(&fmt, tp), opacity); + let b = photocraft_raster::to_rgba(&fmt, bp); + let t = photocraft_raster::to_rgba(&fmt, tp); + if composite_coverage { + let rgba = photocraft_color::blend::composite(mode, b, t, opacity); + photocraft_raster::from_rgba_into(&fmt, rgba, op); + continue; + } + let cb = [b[0], b[1], b[2]]; + let ct = [t[0], t[1], t[2]]; + let color = if fmt.sample == photocraft_color::SampleType::F32 { + photocraft_color::blend::blend_rgb_hdr(mode, cb, ct) + } else { + photocraft_color::blend::blend_rgb(mode, cb, ct) + }; + // A filter edits this surface, rather than adding a second source-over layer. Mix + // premultiplied results so alpha-preserving filters keep coverage at every opacity, + // while Normal-mode filters that change coverage interpolate it too. + let a = b[3] + (t[3] - b[3]) * opacity; + let mut rgba = [0.0, 0.0, 0.0, a]; + if a > 0.0 { + for c in 0..3 { + rgba[c] = (cb[c] * b[3] * (1.0 - opacity) + color[c] * t[3] * opacity) / a; + } + } photocraft_raster::from_rgba_into(&fmt, rgba, op); } out.write_region(area, &o); @@ -309,7 +370,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, canvas, extent)?; + cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent)?; } Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -317,6 +378,21 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> }) } +/// The reference bounds of smart filter `f`. Render filters work within the smart object, as +/// Photoshop's do: Lens Flare's centre is a fraction of the object, and Clouds and Fibers fill it +/// and size their pattern by it. The object is its placed content's bounds (which leaves out +/// fully transparent edges of the source); the other filters use the canvas, as on a layer. +fn filter_bounds(f: &SmartFilter, placed: &Surface, canvas: Rect) -> Rect { + match placed.content_bounds() { + b if renders_in_object(f) && !b.is_empty() => b, + _ => canvas, + } +} + +fn renders_in_object(f: &SmartFilter) -> bool { + matches!(f.command.as_str(), "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.render.lensFlare") +} + /// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the /// requesting session's authority denies the filter's params: callers surface the error instead of /// rendering (and caching) unfiltered pixels. @@ -419,10 +495,18 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result (0, placed()?), }; let n = filters.len() as f32; + // The object's bounds, found once: the placed stage may have been evicted from the cache, and + // placing it again for every render filter would decode the source each time. + let mut object_bounds: Option = None; for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) { ctx.check()?; ctx.progress(i as f32 / n, ""); - cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent }; + let bounds = match object_bounds { + _ if !renders_in_object(f) => canvas, + Some(b) => b, + None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)), + }; + cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent)?, extent: cur.extent }; stack_cache::put(*key, &cur); } ctx.progress(1.0, ""); @@ -453,11 +537,22 @@ pub fn render_below_filter(doc: &Document, sm: &SmartObject, index: usize) -> Re } /// Re-renders smart layer `l` (which lives in `doc`) in place. Returns false if its source is -/// unavailable (the cache is then left alone). +/// unavailable or a visible filter can't be applied (the cache is then left alone). pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result { + if matches!(&l.content, LayerContent::Smart(sm) if !unapplied_filters(sm).is_empty()) { + return Ok(false); + } refresh_layer_in(doc, l, &JobCtx::new()) } +fn unapplied_filters(sm: &SmartObject) -> Vec { + sm.smart_filters + .iter() + .filter(|f| sm.filters_enabled && f.visible && crate::commands::find(&f.command).is_none()) + .map(|f| f.params.get("name").and_then(Value::as_str).unwrap_or(&f.command).to_string()) + .collect() +} + fn refresh_layer_in(doc: &Document, l: &mut Layer, ctx: &JobCtx) -> Result { let LayerContent::Smart(sm) = &mut l.content else { return Ok(false) }; match render_in(doc, sm, ctx)? { @@ -492,10 +587,14 @@ fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> { fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> { if refresh_in(doc, id, ctx)? { - Ok(()) - } else { - Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) + return Ok(()); } + if let SmartSource::Linked { path } = &smart(doc, id)?.source + && let Err(why @ crate::doc_path::NotFollowed::Refused(_)) = crate::doc_path::resolve(path) + { + return Err(crate::doc_path::refusal("the linked file", path, why)); + } + Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) } fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { @@ -752,8 +851,11 @@ fn convert(s: &mut Session, p: &Value) -> Result { // As with Group Layers, place the result in the top selected root's parent. Insert // before removal so paths shifting in other parents cannot discard unselected content. doc.insert_above(Some(top), so); - for id in ids { - doc.remove(id).ok_or(EngineError::NoLayer(id))?; + for id in &ids { + doc.remove(*id).ok_or(EngineError::NoLayer(*id))?; + } + if let [only] = ids[..] { + retarget_layer_refs(doc, only, new_id); } *active = Some(new_id); Ok(new_id) @@ -762,6 +864,25 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": new_id.0})) } +fn retarget_layer_refs(doc: &mut Document, from: LayerId, to: LayerId) { + let Some((fill_opacity, effects)) = doc.layer(to).map(|l| (l.fill_opacity, l.effects.clone())) else { return }; + for comp in doc.layer_comps.iter_mut().chain(doc.last_document_state.as_mut()) { + for state in comp.states.iter_mut().filter(|state| state.layer == from) { + state.layer = to; + if let Some(appearance) = &mut state.appearance { + appearance.fill_opacity = fill_opacity; + appearance.effects = effects.clone(); + } + } + } + for def in doc.variables.defs.iter_mut().filter(|def| def.layer == from) { + def.layer = to; + } + for slice in doc.slices.list.iter_mut().filter(|slice| slice.layer == Some(from)) { + slice.layer = Some(to); + } +} + // ---------- contents ---------- fn via_copy(s: &mut Session, p: &Value) -> Result { @@ -805,24 +926,27 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id, changed) = s.edit(label, |doc, _| { + let (parent, contents_id, changed, warnings) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; let changed = instances(doc, contents_id); + let mut warnings = std::collections::BTreeSet::new(); for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } + warnings + .extend(unapplied_filters(smart(doc, instance)?).into_iter().map(|n| format!("the {n} smart filter is not supported, so it was not applied"))); refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id, changed)) + Ok((doc.id, contents_id, changed, warnings)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the // command once per copy (#4075). - Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>(), "warnings": warnings})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { @@ -1009,10 +1133,40 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result { }) } +/// The Displace filter, whose map a smart filter keeps embedded in its params. +const DISPLACE: &str = "filter.distort.displace"; + fn set_filter_params(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.smartFilter.setParams"; let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?; let authorize = s.authorize; + // A Displace given another open document as its map embeds that one in place of the old + // (#7423). It is read here, before the edit, from the session; the request is authorized + // first, as the merged params are below. A new `mapPath` stays a path (read next to the + // document on each re-render) and only drops the old embedded map; an empty one, or a + // negative `mapDocument` (the dialog's "None"), names no map. + let new_map_asked = crate::filters_ext::map_document(&new).is_some(); + let new_path = crate::filters_ext::map_path(&new).is_some(); + let displace = { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let sm = smart(doc, layer_param(s, p)?)?; + sm.smart_filters.get(filter_index(CMD, p, sm)?).is_some_and(|f| f.command == DISPLACE) + }; + let new_map = if displace && new_map_asked { + // The old embedded map, if the edit passed it back, gives way to the one named. + let request = crate::filters_ext::displace_request(&new); + if let Some(gate) = authorize { + gate(DISPLACE, &request)?; + } + let mut fp = crate::filters::params_for(DISPLACE, &request).ok_or_else(|| bad(CMD, "unreadable Displace settings"))?; + crate::filters_ext::resolve(s, &mut fp, &request)?; + match fp { + photocraft_algo::FilterParams::Displace { map, .. } => map, + _ => None, + } + } else { + None + }; edit_filters(s, p, "Edit Smart Filter", |sm| { let i = filter_index(CMD, p, sm)?; let f = &mut sm.smart_filters[i]; @@ -1023,6 +1177,14 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result { (Value::Object(old), Value::Object(n)) => old.extend(n), (slot, n) => *slot = n, } + if f.command == DISPLACE { + if let Some(map) = &new_map { + f.params = photocraft_io::smart_map::displace_embed_map(&f.params, map) + .ok_or_else(|| bad(CMD, "the displacement map is too large to keep in a Smart Object's filter"))?; + } else if new_path { + f.params = photocraft_io::smart_map::displace_without_map(&f.params); + } + } // Authorize the underlying command with the complete merged params, including paths // already stored in the document, before committing the edit or starting a worker. if let Some(gate) = authorize { @@ -1056,6 +1218,9 @@ fn delete_filter(s: &mut Session, p: &Value) -> Result { edit_filters(s, p, "Delete Smart Filter", |sm| { let i = filter_index("layer.smartFilter.delete", p, sm)?; sm.smart_filters.remove(i); + if sm.smart_filters.is_empty() { + sm.filter_mask = None; + } Ok(()) }) } @@ -1125,6 +1290,9 @@ fn transfer_filters(s: &mut Session, p: &Value) -> Result { match index { Some(i) => { src.smart_filters.remove(i); + if src.smart_filters.is_empty() { + src.filter_mask = None; + } } None => { src.smart_filters.clear(); @@ -1177,6 +1345,13 @@ fn not_smart(s: &Session) -> std::result::Result<(), String> { if active_smart(s).is_ok() { Err("the layer is already a smart object".into()) } else { Ok(()) } } +/// Convert to Smart Object: refused only when the single targeted layer is already a smart +/// object. A multi-layer selection can still be combined (nesting any smart object in it). +fn convertible_to_smart(s: &Session) -> std::result::Result<(), String> { + convertible(s)?; + if crate::layer_multi_cmds::selected(s).len() <= 1 { not_smart(s) } else { Ok(()) } +} + macro_rules! spec { ($id:literal, $label:literal, $menu:expr, $params:literal, $en:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: $en, run: $run, journal: true } @@ -1193,7 +1368,7 @@ pub fn specs() -> Vec { "Convert to Smart Object", SO, r##"{"layer":id?} (default: combine selected layers)"##, - convertible, + convertible_to_smart, convert ), spec!( diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 1caaff6ce2..585c762977 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -7,6 +7,77 @@ const DEPTHS: [u64; 3] = [8, 16, 32]; const W: i32 = 64; const H: i32 = 48; +/// A partial filter strength keeps the source alpha and interpolates the filtered coverage, at every sample depth. +#[test] +fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() { + for sample in photocraft_color::SampleType::ALL { + let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let mut base = Surface::new(fmt); + let mut filtered = Surface::new(fmt); + base.fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.4, 0.6, 0.5]); + filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.5]); + let before = base.pixel(0, 0); + let after = filtered.pixel(0, 0); + let tolerance = match sample { + photocraft_color::SampleType::U8 => 1.0 / 255.0, + photocraft_color::SampleType::U16 => 1.0 / 65535.0, + photocraft_color::SampleType::F32 => 1e-6, + }; + for opacity in [0.0, 0.01, 0.5, 1.0] { + for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Luminosity] { + let mixed = blend_surfaces(&base, &filtered, mode, opacity).pixel(0, 0); + assert!((mixed[3] - before[3]).abs() <= tolerance); + if mode == BlendMode::Normal { + for c in 0..3 { + assert!((mixed[c] - (before[c] + (after[c] - before[c]) * opacity)).abs() <= tolerance); + } + } + } + } + filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.0]); + let mixed = blend_surfaces(&base, &filtered, BlendMode::Normal, 0.5).pixel(0, 0); + assert!((mixed[3] - before[3] * 0.5).abs() <= tolerance); + for c in 0..3 { + assert!((mixed[c] - before[c]).abs() <= tolerance); + } + } +} + +/// Blending a filter result in Screen, Multiply or Luminosity keeps source-over coverage where the filter changed alpha. +#[test] +fn non_normal_coverage_changes_retain_source_over_semantics() { + for sample in photocraft_color::SampleType::ALL { + let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true }; + let mut base = Surface::new(fmt); + let mut filtered = Surface::new(fmt); + // Input-only, output-only, opaque input with blurred output, and a + // translucent pixel whose alpha happens to be unchanged by this filter. + for (x, (ba, ta)) in [(0.5, 0.0), (0.0, 0.5), (1.0, 0.25), (0.5, 0.5)].into_iter().enumerate() { + let x = x as i32; + base.fill_rect(Rect::new(x, 0, 1, 1), &[0.2, 0.4, 0.6, ba]); + filtered.fill_rect(Rect::new(x, 0, 1, 1), &[0.8, 0.6, 0.4, ta]); + } + for mode in [BlendMode::Screen, BlendMode::Multiply, BlendMode::Luminosity] { + for opacity in [0.0, 0.01, 0.5, 1.0] { + let mixed = blend_surfaces(&base, &filtered, mode, opacity); + for x in 0..4 { + if opacity == 0.0 { + assert_eq!(mixed.pixel(x, 0), base.pixel(x, 0)); + continue; + } + let b = photocraft_raster::to_rgba(&fmt, &base.pixel(x, 0)); + let t = photocraft_raster::to_rgba(&fmt, &filtered.pixel(x, 0)); + let expected = photocraft_color::blend::composite(mode, b, t, opacity); + let mut reference = Surface::new(fmt); + reference.fill_rect(Rect::new(x, 0, 1, 1), &expected); + assert_eq!(mixed.pixel(x, 0), reference.pixel(x, 0), "{sample:?} {mode:?} {opacity} x={x}"); + } + } + } + } +} + +/// A `depth`-bit document with one empty layer. fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); @@ -149,6 +220,26 @@ fn empty_layer_cannot_be_converted() { assert!(s.execute("layer.smartObjects.convertToSmartObject", json!({})).is_err()); } +#[test] +fn convert_to_smart_object_is_disabled_for_a_layer_that_is_already_smart() { + let mut s = session(8); + paint(&mut s); + assert!(s.is_enabled("layer.smartObjects.convertToSmartObject")); + convert(&mut s); + assert!(!s.is_enabled("layer.smartObjects.convertToSmartObject")); +} + +#[test] +fn convert_to_smart_object_stays_enabled_for_a_selection_that_includes_a_smart_object() { + let mut s = session(8); + paint(&mut s); + let smart_id = LayerId(convert(&mut s)); + s.execute("layer.new.layer", json!({})).unwrap(); + let other_id = s.active().unwrap().active_layer.unwrap(); + crate::layer_multi_cmds::set_selection(&mut s, vec![smart_id, other_id], Some(other_id), None).unwrap(); + assert!(s.is_enabled("layer.smartObjects.convertToSmartObject")); +} + #[test] fn smart_filters_are_re_editable() { for depth in [8, 16] { @@ -330,6 +421,37 @@ fn selection_becomes_the_filter_mask() { assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); } +/// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so +/// the next filter takes the new selection instead of the old one. +#[test] +fn removing_the_last_filter_removes_its_filter_mask() { + let mut s = session(8); + paint(&mut s); + let original = flat(&s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + assert!(active_smart(&s).filter_mask.is_some()); + s.execute("layer.smartFilter.delete", json!({})).unwrap(); + assert!(active_smart(&s).filter_mask.is_none(), "the last filter takes its mask along"); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let px = flat(&s); + let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize]; + assert_eq!(at(&px, 6, 20), at(&original, 6, 20), "the old selection is not filtered"); + assert_ne!(at(&px, 30, 20), at(&original, 30, 20), "the new selection is filtered"); + + // Dragging the only filter to another smart object leaves no mask behind either. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(a)).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty() && sm.filter_mask.is_none()); +} + /// Scales `id` to 10 % around the origin and back, returning the composite. fn shrink_and_grow(s: &mut Session, id: u64) -> Vec<[f32; 4]> { s.execute("edit.transform", json!({"layer": id, "matrix": [0.1, 0, 0, 0.1, 0, 0]})).unwrap(); @@ -1108,3 +1230,83 @@ fn displace_smart_filter_reads_its_map_path_on_re_render() { } std::fs::remove_dir_all(&dir).ok(); } + +/// Clouds and Difference Clouds on a smart object are smart filters, as in Photoshop, and keep +/// the colours they were drawn with: re-rendering after the colours change gives the same clouds. +#[test] +fn clouds_on_a_smart_object_are_smart_filters_with_their_colours() { + for depth in DEPTHS { + let mut s = session(depth); + paint(&mut s); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + s.tools.background = [0.0, 0.0, 1.0, 1.0]; + s.execute("filter.render.clouds", json!({"seed": 5})).unwrap(); + s.execute("filter.render.differenceClouds", json!({"seed": 9})).unwrap(); + let sm = active_smart(&s); + let commands: Vec<&str> = sm.smart_filters.iter().map(|f| f.command.as_str()).collect(); + assert_eq!(commands, ["filter.render.clouds", "filter.render.differenceClouds"]); + assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.smart_filters[1].params["background"], json!([0.0, 0.0, 1.0, 1.0])); + // Red-to-blue clouds, and their difference, have no green: they fill the object (painted + // from (-6, 5) to (40, 38)) over the white background. + let rendered = flat(&s); + let object: Vec<[f32; 4]> = (5..38).flat_map(|y| (0..40).map(move |x| (y * W + x) as usize)).map(|i| rendered[i]).collect(); + assert!(object.iter().all(|p| p[1] < 0.02), "depth {depth}"); + assert!(max_diff(&object, &vec![object[0]; object.len()]) > 0.05, "the clouds vary"); + s.tools.foreground = [0.0, 1.0, 0.0, 1.0]; + s.execute("layer.smartFilter.setParams", json!({"index": 1, "params": {"seed": 9}})).unwrap(); + assert!(max_diff(&flat(&s), &rendered) < 1e-6, "depth {depth}: same params, same clouds"); + s.undo(); + s.undo(); + assert_eq!(active_smart(&s).smart_filters.len(), 1); + } +} + +/// Render filters on a smart object work within the object, as in Photoshop (and as PSD stores +/// them): Lens Flare's centre is a fraction of the object's bounds, not of the canvas. +#[test] +fn lens_flare_centres_on_the_smart_object() { + let mut s = session(8); + let block = Rect::new(32, 24, W, H); + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let px = photocraft_raster::from_rgba(&fmt, [0.1, 0.1, 0.1, 1.0]); + surf.write_region(block, &px.repeat((block.width() * block.height()) as usize)); + Ok(()) + }) + .unwrap(); + convert(&mut s); + s.execute("filter.render.lensFlare", json!({"centerX": 0.5, "centerY": 0.5, "lens": "prime105"})).unwrap(); + // The centre of the brightest pixels inside the object (the canvas around it is white). + let px = flat(&s); + let inside: Vec<(i32, i32, f32)> = + (block.y0..block.y1).flat_map(|y| (block.x0..block.x1).map(move |x| (x, y))).map(|(x, y)| (x, y, px[(y * W + x) as usize][1])).collect(); + let peak = inside.iter().map(|p| p.2).fold(0.0, f32::max); + let core: Vec<_> = inside.iter().filter(|p| p.2 >= peak - 0.02).collect(); + let (x, y) = (core.iter().map(|p| p.0).sum::() / core.len() as i32, core.iter().map(|p| p.1).sum::() / core.len() as i32); + assert!((x - 48).abs() <= 1 && (y - 36).abs() <= 1, "flare at {x},{y}, the object's centre is 48,36"); +} + +/// Moving a smart object moves its Clouds and Fibers with it: the pattern is anchored at the +/// object, not at the canvas, so re-rendering after the move doesn't change the texture. +#[test] +fn clouds_and_fibers_move_with_the_smart_object() { + for (command, params) in [("filter.render.clouds", json!({"seed": 3})), ("filter.render.fibers", json!({"seed": 3}))] { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + s.execute(command, params).unwrap(); + let before = flat(&s); + s.execute("edit.transform", json!({"layer": id, "matrix": [1, 0, 0, 1, 8, 6]})).unwrap(); + let after = flat(&s); + // Inside the object (painted from (-6, 5) to (40, 38)) both before and after the move. + for y in 8..30 { + for x in 2..30 { + let (a, b) = (before[(y * W + x) as usize], after[((y + 6) * W + x + 8) as usize]); + assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "{command} at {x},{y}: {a:?} vs {b:?}"); + } + } + } +} diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..039f248a5b 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -221,12 +221,20 @@ impl ObjectFinderCache { /// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer /// above the photo first). fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { - let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; - if surf.content_bounds().intersect(&area).is_empty() { - let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; - return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + match empty_sampled_area(doc, layer, all_layers, area) { + Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))), + None => Ok(()), } - Ok(()) +} + +/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there. +/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint. +pub fn empty_sampled_area(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Option<&'static str> { + let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?; + if !surf.content_bounds().intersect(&area).is_empty() { + return None; + } + Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }) } /// `select.object` with `"point"`: the Object Finder's object under the point (cached). @@ -696,6 +704,22 @@ mod tests { assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); } + #[test] + fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() { + for depth in [8, 16, 32] { + let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y); + let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap(); + assert_eq!(r["changed"], false, "background at depth {depth}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + } + #[test] fn object_selection_point_honours_the_modes() { let (mut s, _) = disc(8); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 3ab1ad73bf..78c8b9f2e8 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -89,10 +89,21 @@ pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> { } fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> { - if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) { - return None; - } - let b = crate::transform_cmds::transform_bounds(doc, l); + snappable(l).then(|| crate::transform_cmds::transform_bounds(doc, l)).and_then(to_rect) +} + +/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection +/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the +/// selection's, so a moved selection snapped back to where it started). +fn target_rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> { + snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(doc, l)).and_then(to_rect) +} + +fn snappable(l: &photocraft_doc::Layer) -> bool { + !(l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none())) +} + +fn to_rect(b: photocraft_geom::Rect) -> Option<[f64; 4]> { (!b.is_empty()).then_some([b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]) } @@ -121,7 +132,14 @@ impl SnapTargets { if !l.visible || exclude.contains(&l.id) { continue; } - if let Some(r) = rect_of(doc, l) { + // An artboard is a group, but its edges and centre are the board's own. + if let Some(a) = l.artboard() { + if let Some(r) = to_rect(a.rect) { + t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); + } + continue; + } + if let Some(r) = target_rect_of(doc, l) { t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); } } @@ -365,6 +383,42 @@ mod tests { let t = SnapTargets::from_document(&doc, &SnapOptions { guides: false, ..opts }, &[id]); assert!(!t.x.iter().any(|t| t.kind == SnapKind::Guide || (t.kind == SnapKind::LayerEdge && t.pos == 30.0))); assert_eq!(layer_rect(&doc, id), Some([10.0, 20.0, 30.0, 60.0])); + // #6290: a selection doesn't move the layers' targets onto its own bounds. + let mut sel = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + sel.fill_rect(photocraft_geom::Rect::new(64, 66, 124, 86), &[1.0]); + doc.selection = Some(sel); + let t = SnapTargets::from_document(&doc, &SnapOptions { selection: false, ..opts }, &[]); + assert!(t.x.iter().any(|t| t.kind == SnapKind::LayerEdge && t.pos == 30.0)); + assert!( + !t.x.iter() + .chain(&t.y) + .any(|t| matches!(t.kind, SnapKind::LayerEdge | SnapKind::LayerCenter) && [64.0, 66.0, 76.0, 86.0, 94.0, 124.0].contains(&t.pos)) + ); assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0])); } + + /// An artboard (a group) offers the board's own edges and centre, so other layers, boards and + /// tools can snap to it; the board being moved doesn't snap to itself. + #[test] + fn an_artboard_offers_its_own_edges() { + let mut doc = Document::with_background("s", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut board = Layer::group("Artboard 1", vec![]); + if let photocraft_doc::LayerContent::Group(g) = &mut board.content { + g.artboard = + Some(photocraft_doc::Artboard { rect: photocraft_geom::Rect::new(20, 10, 120, 90), background: Default::default(), preset: String::new() }); + } + let id = doc.insert_above(None, board); + let opts = SnapOptions::default(); + let t = SnapTargets::from_document(&doc, &opts, &[]); + let has = |ts: &[Target], kind: SnapKind, pos: f64| ts.iter().any(|t| t.kind == kind && t.pos == pos); + for x in [20.0, 120.0] { + assert!(has(&t.x, SnapKind::LayerEdge, x), "x = {x}"); + } + for y in [10.0, 90.0] { + assert!(has(&t.y, SnapKind::LayerEdge, y), "y = {y}"); + } + assert!(has(&t.x, SnapKind::LayerCenter, 70.0) && has(&t.y, SnapKind::LayerCenter, 50.0)); + let t = SnapTargets::from_document(&doc, &opts, &[id]); + assert!(!has(&t.x, SnapKind::LayerEdge, 120.0) && !has(&t.x, SnapKind::LayerCenter, 70.0)); + } } diff --git a/crates/engine/src/stamp_cmds.rs b/crates/engine/src/stamp_cmds.rs index a525d138b5..76af35627a 100644 --- a/crates/engine/src/stamp_cmds.rs +++ b/crates/engine/src/stamp_cmds.rs @@ -1,7 +1,8 @@ //! Stamping (#217): merge layers into a layer while keeping the originals. //! //! - **Stamp Visible** (⌘⌥⇧E, or ⌥ + Layer › Merge Visible): every visible layer composited into a -//! new layer above the active one. +//! new layer above the active one, or into the active layer when it is an empty pixel layer (as +//! in Photoshop, so a recorded New Layer + Stamp Visible leaves one layer, #6785). //! - **Stamp Down** (⌘⌥E, or ⌥ + Layer › Merge Down): a copy of the active layer merged into the //! pixel layer below it; with several layers selected, a copy of them merged into a new layer //! above the top one. @@ -65,6 +66,13 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { if !doc.walk().iter().any(|(path, _, l)| !l.is_group() && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))) { return Err(EngineError::Other("there are no visible layers to stamp".into())); } + // Like Photoshop, an empty pixel layer that is active (Layer > New > Layer, then Stamp + // Visible, as recorded actions do) takes the stamp itself instead of getting a second layer. + if let Some(empty) = (*active).filter(|id| is_empty_pixel_layer(doc, *id)) { + let surf = photocraft_compose::flatten_to_surface(doc, with_alpha(doc), None); + *crate::pixels_mut(doc.layer_mut(empty).ok_or(EngineError::NoLayer(empty))?)? = surf; + return Ok(empty); + } let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?; let id = doc.insert_above(*active, layer); *active = Some(id); @@ -74,6 +82,18 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { Ok(json!({ "layer": id.0 })) } +/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into. +fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool { + let Some(l) = doc.layer(id) else { return false }; + let locks = doc.effective_locks(id); + matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()) + && l.visible + && l.mask.is_none() + && l.vector_mask.is_none() + && !l.clipped + && !(locks.all || locks.pixels || locks.transparency) +} + fn stamp_down(s: &mut Session, p: &Value) -> Result { object(p, "layer.stampDown")?; let sel = crate::layer_multi_cmds::selected(s); @@ -101,7 +121,7 @@ fn stamp_down(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("the layer is hidden".into())); } // The two layers on their own, composited in bands; the lower one keeps its id, name and - // blend mode, as Merge Down does. + // blend mode, clipping, label and link, as Merge Down does. let mut tmp = doc.clone(); let mut base = lower.clone(); base.visible = true; @@ -114,6 +134,9 @@ fn stamp_down(s: &mut Session, p: &Value) -> Result { merged.blend = lower.blend; merged.visible = lower.visible; merged.locks = lower.locks; + merged.clipped = lower.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged.opacity = 1.0; *doc.layer_at_mut(&below).ok_or(EngineError::NoLayer(lower.id))? = merged; Ok(()) @@ -148,7 +171,7 @@ pub fn specs() -> Vec { label: "Stamp Visible", menu: &[], shortcut: Some("Cmd+Alt+Shift+E"), - params: r##"{} → {layer} (every visible layer merged into a new layer above the active one; the originals stay)"##, + params: r##"{} → {layer} (visible layers merged into a new layer above the active one, or into the active one if it is an empty pixel layer)"##, enabled: has_doc, journal: true, run: stamp_visible, @@ -235,6 +258,25 @@ mod tests { } } + /// Recorded actions do New Layer, then Stamp Visible: the stamp lands in the new empty layer. + #[test] + fn stamp_visible_fills_an_empty_active_layer_instead_of_adding_one() { + let (mut s, bg, hidden, blue) = session(8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + let r = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s), [bg, hidden, blue, empty], "no extra layer"); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers.clone()), (Some(empty), vec![empty])); + assert!(close(rgba(&s, empty, 30, 30), [0.0, 0.0, 1.0, 1.0])); + assert!(close(rgba(&s, empty, 2, 2), [1.0, 0.0, 0.0, 1.0])); + // A second stamp: the layer now has pixels, so this one adds a layer. + let r2 = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_ne!(r2["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s).len(), 5); + } + #[test] fn stamp_visible_respects_opacity_blend_and_groups() { let (mut s, bg, _, blue) = session(8); @@ -285,6 +327,23 @@ mod tests { assert!(close(rgba(&s, hidden, 30, 30), [0.0, 1.0, 0.0, 1.0])); } + #[test] + fn stamp_down_keeps_the_lower_layers_clipping_label_and_link() { + let (mut s, _, hidden, blue) = session(8); + let l = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(hidden).unwrap(); + l.visible = true; + l.clipped = true; + l.label = photocraft_doc::LabelColor::Red; + l.link_group = Some(7); + s.execute("layer.stampDown", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(hidden).unwrap(); + assert!(close(rgba(&s, hidden, 30, 30), [0.0, 0.0, 1.0, 1.0]), "stamped"); + assert!(l.clipped, "still clipped to the layer below"); + assert_eq!(l.label, photocraft_doc::LabelColor::Red); + assert_eq!(l.link_group, Some(7)); + assert_eq!(s.active().unwrap().active_layer, Some(blue)); + } + #[test] fn stamp_down_refuses_what_it_cannot_do() { let (mut s, bg, hidden, blue) = session(8); diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs index 7844124bdb..fc9a4c36b1 100644 --- a/crates/engine/src/swatch_cmds.rs +++ b/crates/engine/src/swatch_cmds.rs @@ -328,6 +328,13 @@ fn use_swatch(s: &mut Session, p: &Value) -> Result { "background" => s.tools.background = c, t => return Err(bad(CMD, format!("unknown target `{t}` (foreground|background)"))), } + // A CMYK swatch keeps its inks (as a colour picked in CMYK does, #4196): the Color Picker + // shows them and a CMYK document is painted with them, not with the sRGB round trip (#7474). + if let SwatchColor::Cmyk(inks) = sw.color + && inks.iter().all(|v| v.is_finite()) + { + s.tools.remember_cmyk(rgb, inks.map(|v| v.clamp(0.0, 1.0))); + } Ok(json!({"swatch": sw.name, "target": target, "hex": hex(rgb)})) } @@ -362,6 +369,7 @@ fn reset(s: &mut Session, p: &Value) -> Result { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Aco, + Act, Ase, } @@ -369,6 +377,7 @@ impl Format { fn parse(s: &str) -> Option { match s.trim_start_matches('.').to_ascii_lowercase().as_str() { "aco" => Some(Format::Aco), + "act" => Some(Format::Act), "ase" => Some(Format::Ase), _ => None, } @@ -376,6 +385,7 @@ impl Format { fn id(self) -> &'static str { match self { Format::Aco => "aco", + Format::Act => "act", Format::Ase => "ase", } } @@ -462,6 +472,21 @@ fn kind_to_ase(k: SwatchKind) -> AseKind { } } +/// Colors in an `.act` color table (what the format holds). +pub const ACT_COLORS: usize = photocraft_psd::act::COLOR_COUNT; + +/// `.act` bytes as `#rrggbb` colors plus the transparent index, for the Color Table dialog. +pub fn act_table(bytes: &[u8]) -> std::result::Result<(Vec, Option), String> { + let f = photocraft_psd::act::parse(bytes).map_err(|e| e.to_string())?; + let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]); + Ok((f.used().iter().map(hex).collect(), f.transparent_index().map(|i| i as u64))) +} + +/// Encode colors as `.act` bytes (at most [`ACT_COLORS`]); the transparent index is kept. +pub fn act_bytes(colors: &[[u8; 3]], transparent: Option) -> std::result::Result, String> { + photocraft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string()) +} + /// Parse a swatch file into groups (`group` names the group for ungrouped colours). pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Result<(Vec>, Vec), String> { let mut warnings = Vec::new(); @@ -484,6 +509,19 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu .collect(); out.push(Group::new(group, items)); } + Format::Act => { + let f = photocraft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?; + if let Some(t) = f.transparent_index() { + warnings.push(format!("color {} is the table's transparent color; swatches have no transparency so it was kept opaque", t + 1)); + } + let items = f + .used() + .iter() + .enumerate() + .map(|(i, c)| Swatch { name: format!("Swatch {}", i + 1), color: SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0)), kind: SwatchKind::Process }) + .collect(); + out.push(Group::new(group, items)); + } Format::Ase => { let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?; let mut skipped = 0; @@ -541,6 +579,32 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?; Ok((bytes, n, warnings)) } + Format::Act => { + // `.act` holds RGB only: HSB rides along as RGB, anything else is left out. + let rgb8 = |v: [f32; 3]| v.map(|c| (unit(c) * 255.0).round() as u8); + let mut triplets = Vec::new(); + let mut skipped = 0; + for s in groups.iter().flat_map(|g| g.items.iter()) { + match s.color { + SwatchColor::Rgb(v) => triplets.push(rgb8(v)), + SwatchColor::Hsb(v) => triplets.push(rgb8(hsb_to_rgb(v))), + _ => skipped += 1, + } + } + if groups.len() > 1 { + warnings.push(".act files have no groups; the swatches were saved as one list".into()); + } + if skipped > 0 { + warnings.push(format!("{skipped} non-RGB swatches can't be stored in an .act file and were left out (save as .aco to keep them)")); + } + if triplets.len() > photocraft_psd::act::COLOR_COUNT { + warnings.push(format!("only the first {} swatches fit in an .act file", photocraft_psd::act::COLOR_COUNT)); + triplets.truncate(photocraft_psd::act::COLOR_COUNT); + } + let n = triplets.len(); + let bytes = photocraft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?; + Ok((bytes, n, warnings)) + } Format::Ase => { let mut books = 0; let mut entries = Vec::with_capacity(groups.len()); @@ -571,12 +635,18 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu fn import(s: &mut Session, p: &Value) -> Result { const CMD: &str = "swatches.import"; - let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco or .ase file")?; + let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco, .act or .ase file")?; + // `.act` has no magic: exactly 768/772 bytes. An `.act` whose first colour is `0, 1, x` or + // `0, 2, x` starts like an `.aco`, and the .aco reader ignores trailing bytes, so at those + // lengths an .aco header only counts when its sections fill the file exactly. + let act_len = bytes.len() == photocraft_psd::act::PALETTE_LEN || bytes.len() == photocraft_psd::act::PALETTE_LEN + photocraft_psd::act::TRAILER_LEN; let format = match str_param(p, "format") { - Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?, + None if ext_format(str_param(p, "path")) == Some(Format::Act) => Format::Act, None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase, - None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) => Format::Aco, - None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco or .ase swatch file"))?, + None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) && (!act_len || photocraft_psd::aco::sections_len(&bytes) == Some(bytes.len())) => Format::Aco, + None if act_len => Format::Act, + None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco, .act or .ase swatch file"))?, }; let mode = str_param(p, "mode").unwrap_or("append"); let replace = match mode { @@ -634,7 +704,7 @@ fn export(s: &mut Session, p: &Value) -> Result { const CMD: &str = "swatches.export"; let path = str_param(p, "path"); let format = match str_param(p, "format") { - Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?, None => ext_format(path).unwrap_or(Format::Aco), }; let mut groups: Vec> = match name_param(p, "group", CMD)? { @@ -790,7 +860,7 @@ pub fn specs() -> Vec { label: "Import Swatches…", menu: &[], shortcut: None, - params: r##"{"path":".aco or .ase file"?,"data":base64 bytes?,"format":"aco|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##, + params: r##"{"path":".aco, .act or .ase file"?,"data":base64 bytes?,"format":"aco|act|ase"? (detected; an .act by its .act path or its 768/772-byte length),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##, enabled: always, run: import, journal: true, @@ -800,7 +870,7 @@ pub fn specs() -> Vec { label: "Export Swatches…", menu: &[], shortcut: None, - params: r##"{"format":"aco|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##, + params: r##"{"format":"aco|act|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##, enabled: has_swatches, run: export, journal: true, diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 2a3e8e7ddb..54fadda05c 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -57,6 +57,25 @@ fn use_sets_foreground_and_background_through_colour_management() { assert!(s.tools.background.iter().all(|v| (v - 1.0).abs() < 0.01), "{:?}", s.tools.background); } +/// #7474: a CMYK swatch (an .ase Pantone colour) keeps its inks when it becomes the foreground, +/// instead of reading back as what its sRGB stand-in converts to (0/0/100/0 -> 5/1/92/0). +#[test] +fn using_a_cmyk_swatch_remembers_its_inks() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "P 1-8 C", "kind": "spot", "color": {"model": "cmyk", "values": [0, 0, 100, 0]}})).unwrap(); + s.execute("swatches.use", json!({"swatch": "P 1-8 C"})).unwrap(); + let fg = s.tools.foreground; + assert_eq!(s.tools.picked_cmyk([fg[0], fg[1], fg[2]]), Some([0.0, 0.0, 1.0, 0.0])); + s.execute("swatches.add", json!({"name": "Print cyan", "color": {"model": "cmyk", "values": [100, 0, 0, 0]}})).unwrap(); + s.execute("swatches.use", json!({"swatch": "Print cyan", "target": "background"})).unwrap(); + let bg = s.tools.background; + assert_eq!(s.tools.picked_cmyk([bg[0], bg[1], bg[2]]), Some([1.0, 0.0, 0.0, 0.0])); + // An RGB swatch records nothing. + s.execute("swatches.use", json!({"swatch": "Red"})).unwrap(); + let fg = s.tools.foreground; + assert_eq!(s.tools.picked_cmyk([fg[0], fg[1], fg[2]]), None); +} + #[test] fn rename_delete_move_and_groups() { let mut s = Session::new(); @@ -146,6 +165,64 @@ fn aco_round_trip_keeps_16_bit_values_and_books() { std::fs::remove_dir_all(&dir).ok(); } +#[test] +fn act_round_trip_loads_a_photoshop_color_table() { + // A Photoshop-saved table: 256 RGB colors plus the count/transparent trailer. + let mut colors = [[0u8; 3]; 256]; + colors[0] = [255, 255, 255]; + for (i, c) in colors.iter_mut().enumerate().skip(1) { + let v = i as u8; + *c = [v, 255 - v, 128]; + } + let file = photocraft_psd::act::write(&colors, Some(256), Some(255)).unwrap(); + assert_eq!(file.len(), 772); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Table", "mode": "replace"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256))); + assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the transparent color is reported"); + assert_eq!(s.presets.swatches[0].items[0].color, SwatchColor::Rgb([1.0, 1.0, 1.0])); + assert_eq!(s.presets.swatches[0].items[1].name, "Swatch 2"); + // Sniffed by length without a format, and by extension from a path. + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); + assert_eq!(r["format"].as_str(), Some("act")); + // Export keeps the 256 RGB colors; the trailer comes back in canonical form + // (swatches have no transparency to preserve). + let canon = photocraft_psd::act::write(&colors, Some(256), None).unwrap(); + assert_eq!(export_bytes(&mut s, json!({"format": "act"})), canon); + // A bigger library is capped at 256 with a warning; groups collapse to one list. + for i in 0..300 { + s.execute("swatches.add", json!({"name": format!("Extra {i}"), "color": {"model": "rgb", "values": [1, 0, 0]}})).unwrap(); + } + let r = s.execute("swatches.export", json!({"format": "act"})).unwrap(); + assert_eq!(r["count"], 256); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("first 256")))); +} + +#[test] +fn act_that_starts_like_an_aco_is_still_an_act() { + // First colour 0, 1, 0 and second colour 5, …: the first four bytes read as an .aco version 1 + // header with five swatches, and the .aco reader would take 50 bytes of junk from it. + let mut colors = [[0u8; 3]; 256]; + colors[0] = [0, 1, 0]; + for (i, c) in colors.iter_mut().enumerate().skip(1) { + *c = [(i as u8).wrapping_add(4), 7, 9]; + } + for file in [photocraft_psd::act::write(&colors, None, None).unwrap(), photocraft_psd::act::write(&colors, Some(256), None).unwrap()] { + assert_eq!(&file[..4], &[0, 1, 0, 5]); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)), "{} bytes", file.len()); + } + // A real .aco of exactly 768 bytes (ten swatches with 25-character names) stays an .aco. + let swatches: Vec = (0..10u16).map(|i| AcoSwatch { name: format!("{i:0>25}"), color: AcoColor::Rgb([i * 1000, 0, 65535]) }).collect(); + let file = photocraft_psd::aco::write(&swatches).unwrap(); + assert_eq!(file.len(), photocraft_psd::act::PALETTE_LEN); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(10))); + assert_eq!(s.presets.swatches[0].items[9].name, format!("{:0>25}", 9)); +} + #[test] fn ase_round_trip_keeps_groups_floats_and_kinds() { let src = AseFile { diff --git a/crates/engine/src/symmetry_cmds.rs b/crates/engine/src/symmetry_cmds.rs index 2339568a0d..4b93d797de 100644 --- a/crates/engine/src/symmetry_cmds.rs +++ b/crates/engine/src/symmetry_cmds.rs @@ -4,7 +4,7 @@ use photocraft_doc::{LayerContent, vector::Path}; use photocraft_paint::{ StrokePoint, - symmetry::{PresetSymmetry, SymmetryMode}, + symmetry::{MAX_COORDINATE, PresetSymmetry, SymmetryMode}, }; use serde_json::{Value, json}; @@ -58,6 +58,44 @@ impl PaintingSymmetry { Self::Preset(p) => json!({"mode":p.mode.id(), "center":p.center, "rotation":p.rotation}), } } + /// The symmetry after a canvas edit (crop, Canvas Size, Image Size, rotation) moved the pixels + /// through `a`, so the axis stays on the same image content. `None` when it lands beyond the + /// coordinate limits. + pub fn transform(&self, a: &photocraft_geom::Affine) -> Option { + let map = |[x, y]: [f64; 2]| { + let q = a.apply(photocraft_geom::Point::new(x, y)); + [q.x, q.y].into_iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE).then_some([q.x, q.y]) + }; + match self { + Self::Path(axis) => { + let segments = axis.segments.iter().map(|&[p, q]| Some([map(p)?, map(q)?])).collect::>()?; + Some(Self::Path(SymmetryAxis { source: axis.source.clone(), segments })) + } + Self::Preset(p) => { + // The first axis' direction through the map's linear part gives the new angle; a + // flip mirrors it. + let directions = p.directions(); + let [dx, dy] = *directions.first()?; + let [m0, m1, m2, m3, _, _] = a.m; + let mapped = |[dx, dy]: [f64; 2]| (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy); + let mut axis = mapped([dx, dy]); + // A non-uniform Image Size leaves a turned dual preset's two axes non-perpendicular, + // which it can't represent: the new axes split the difference, each turned off its + // mapped axis by the same angle. Maps that keep right angles give no difference. + if let Some(&second) = directions.get(1) { + let off = (mapped(second) - axis - std::f64::consts::FRAC_PI_2).rem_euclid(std::f64::consts::PI); + let off = if off > std::f64::consts::FRAC_PI_2 { off - std::f64::consts::PI } else { off }; + axis += off / 2.0; + } + let turn = axis - dy.atan2(dx); + let rotation = (p.rotation + turn.to_degrees()).rem_euclid(360.0); + if !rotation.is_finite() { + return None; + } + Some(Self::Preset(PresetSymmetry { center: map(p.center)?, rotation, ..*p })) + } + } + } } fn set_preset(s: &mut Session, p: &Value) -> Result { @@ -246,6 +284,42 @@ mod tests { assert!(SymmetryAxis::from_path("long".into(), &Path::new(vec![long])).is_err(), "path complexity remains bounded"); } + #[test] + fn canvas_edits_move_the_symmetry_with_the_pixels() { + let preset = |s: &Session| s.active().unwrap().symmetry.as_ref().unwrap().preset().unwrap(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setSymmetry", json!({"mode": "vertical"})).unwrap(); + s.execute("image.crop", json!({"x": 40, "y": 0, "width": 60, "height": 100})).unwrap(); + assert_eq!(preset(&s).center, [10.0, 50.0], "the axis stays on the pixels it was on"); + s.execute("image.imageSize", json!({"width": 120, "height": 200})).unwrap(); + assert_eq!(preset(&s).center, [20.0, 100.0]); + s.execute("paint.setSymmetry", json!({"mode": "diagonal", "center": [30, 40]})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + let p = preset(&s); + assert_eq!(p.center, [90.0, 40.0], "edge coordinates mirror about the canvas width, not the last pixel"); + assert!((p.rotation - 90.0).abs() < 1e-9, "the 45° axis mirrors to 135°: {}", p.rotation); + // A turned dual preset under a non-uniform Image Size: its axes (135° and 45°) map to + // 153.4° and 26.6°, no longer perpendicular; the preset splits the difference. + s.execute("paint.setSymmetry", json!({"mode": "dual", "center": [30, 40], "rotation": 45})).unwrap(); + s.execute("image.imageSize", json!({"width": 240, "height": 200})).unwrap(); + let p = preset(&s); + assert_eq!(p.center, [60.0, 40.0]); + assert!((p.rotation - 45.0).abs() < 1e-9, "both axes 18.4° off, not one 0° and one 36.9°: {}", p.rotation); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + assert!((preset(&s).rotation - 315.0).abs() < 1e-9, "a flip keeps right angles (axes at 45° and 135° again): {}", preset(&s).rotation); + + // A path axis keeps lying on its (moved) source path. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [60, 100]]}]}})).unwrap(); + s.execute("paint.symmetryFromPath", json!({"name": "work"})).unwrap(); + s.execute("image.crop", json!({"x": 30, "y": 10, "width": 50, "height": 80})).unwrap(); + let st = s.active().unwrap(); + let moved = SymmetryAxis::from_path("work".into(), st.doc.work_path.as_ref().unwrap()).unwrap(); + assert_eq!(st.symmetry, Some(PaintingSymmetry::Path(moved))); + } + #[test] fn symmetry_paints_mirrored_pixels_and_disable_stops_it() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..0b1b48aa3b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -158,6 +158,33 @@ fn file_new_keeps_defaults_and_supported_modes_and_depths() { } } +#[test] +fn file_new_tags_the_document_with_the_chosen_profile() { + // #321: New Document › Color Profile, as in Photoshop: another profile than the working space. + let desc = |s: &Session| { + let bytes = s.active().unwrap().doc.icc_profile.clone(); + bytes.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description) + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "profile": "adobe-rgb-compat"})).unwrap(); + assert_eq!(desc(&s).as_deref(), Some(photocraft_cms::Builtin::AdobeRgbCompat.description())); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk", "profile": "coated-cmyk"})).unwrap(); + assert!(desc(&s).is_some()); + // The working space (the default, or asked for) leaves it as before. + for p in [json!({"width": 4, "height": 4}), json!({"width": 4, "height": 4, "profile": "working"})] { + s.execute("file.new", p).unwrap(); + assert_eq!(desc(&s), None); + } + // A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing. + let open = s.documents().len(); + let (active, steps) = (s.active().unwrap().doc.id, s.active().unwrap().history.past_len()); + for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] { + assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}"); + assert_eq!(s.documents().len(), open, "{bad}"); + assert_eq!((s.active().unwrap().doc.id, s.active().unwrap().history.past_len()), (active, steps), "{bad}"); + } +} + #[test] fn file_new_variants() { let mut s = Session::new(); @@ -635,6 +662,33 @@ fn fill_layers_and_colors() { assert!(l < r); } +#[test] +fn gradient_fill_layer_angles_are_finite_and_normalised() { + // `angle` 1e39 overflowed f32 to infinity: the render went NaN and .pcraft refused to save. + let mut s = session_with_doc(); + let angle = |s: &Session| { + let st = s.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Gradient { angle, .. }) => *angle, + _ => panic!("not a gradient fill"), + } + }; + for cmd in ["layer.newFillLayer.gradient", "gradient.presets.apply"] { + let layers = s.active().unwrap().doc.layers.len(); + for bad in [json!(1e39), json!(-1e300), json!("90")] { + assert!(s.execute(cmd, json!({"angle": bad})).is_err(), "{cmd} {bad}"); + } + assert_eq!(s.active().unwrap().doc.layers.len(), layers, "{cmd}: nothing added"); + // Normalised to (-180, 180] like gradient.fill.set. + for (a, want) in [(450.0, 90.0), (-90.0, -90.0), (-180.0, 180.0)] { + s.execute(cmd, json!({"angle": a})).unwrap(); + assert_eq!(angle(&s), want, "{cmd} {a}"); + } + s.execute(cmd, json!({})).unwrap(); + assert_eq!(angle(&s), 90.0, "{cmd}: default"); + } +} + #[test] fn default_colors_with_mask_targeted_resets_to_mask_colors() { let mut s = Session::new(); @@ -702,6 +756,38 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { assert_eq!(after, fresh, "the effect maps track the cache surface, not the Fill spec alone"); } +#[test] +fn growing_the_canvas_re_renders_a_cached_fill_layer() { + // A PSD fill layer keeps its stored pixels as a fill cache covering only the old canvas. + // Canvas Size, an extending Crop and Reveal All must re-render the fill over the new area, + // as Photoshop does, instead of leaving the added bands transparent (#7362). + let grow: [(&str, Value); 2] = [ + ("image.canvasSize", json!({"width": 100, "anchor": "left"})), + ("image.crop", json!({"x": 0, "y": 0, "width": 100, "height": 48, "deleteCroppedPixels": false})), + ]; + for (cmd, params) in grow { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap(); + s.edit("cache", |doc, active| { + let fmt = doc.pixel_format(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Fill(f) = &l.content else { return Err(EngineError::Other("not a fill layer".into())) }; + let fill = f.clone(); + let mut surface = photocraft_raster::Surface::new(fmt); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[1.0, 0.0, 0.0, 1.0]); + l.fill_cache = Some(photocraft_doc::FillCache { fill, surface }); + Ok(()) + }) + .unwrap(); + s.execute(cmd, params).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.size.width, 100, "{cmd} grows the canvas"); + let px = photocraft_compose::render(d, d.bounds()).get(90, 20); + assert!(px[3] > 0.99 && px[0] > 0.99 && px[1] < 0.01, "{cmd}: the fill covers the added area, got {px:?}"); + } +} + #[test] fn group_effect_maps_follow_child_visibility_and_opacity() { // A styled group's maps come from its children's composite: hiding a child or changing its @@ -1129,6 +1215,55 @@ fn selecting_a_layer_does_not_dirty_the_document() { assert!(s.active().unwrap().is_dirty()); } +#[test] +fn selection_tools_do_not_dirty_the_document() { + // #6865: marquee, lasso, Select All / Inverse / Deselect and their undo and redo are + // history steps, but not edits, so a clean document stays clean. + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 20, "width": 8, "height": 8, "ellipse": true, "mode": "add"})).unwrap(); + s.execute("select.lasso", json!({"points": [[0, 0], [40, 0], [20, 30]], "mode": "subtract"})).unwrap(); + s.execute("select.inverse", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().history.past_len() > past, "selections are still history steps"); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: selecting is not an edit"); + assert!(s.undo() && s.undo()); + assert!(s.redo()); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: undoing a selection is not an edit"); + s.execute("history.selectState", json!({"state": past})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "depth {depth}: stepping back over selections is not an edit"); + // A real edit still dirties, with or without a selection, and a selection can't clean it. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty()); + assert!(s.undo()); + assert!(s.active().unwrap().is_dirty(), "the new layer is still unsaved"); + } +} + +#[test] +fn selections_keep_a_document_with_a_video_layer_clean() { + // #6865: video frames compare by tile pointer, so selecting in a document with a video + // layer doesn't mark it modified either; changing the frames still does. + let mut s = session_with_doc(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + let st = s.active_mut().unwrap(); + st.saved_revision = st.revision; + s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + assert!(!s.active().unwrap().is_dirty(), "selecting is not an edit"); + assert!(s.undo()); + assert!(!s.active().unwrap().is_dirty(), "undoing a selection is not an edit"); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert!(s.active().unwrap().is_dirty(), "a new frame is an edit"); +} + #[test] fn coalesced_edits_share_one_history_step() { let mut s = Session::new(); @@ -1438,6 +1573,53 @@ fn document_inspect_and_activate_invalid_index() { assert_eq!(err_act.to_string(), "no document at index 9"); } +/// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks the open +/// documents in tab order from the active one. Past either end it is refused, not wrapped onto +/// another document, and bad params leave the active document as it was. +#[test] +fn document_activate_by_offset_walks_the_tabs() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width": 2, "height": 2, "name": name})).unwrap(); + } + let active = |s: &Session| s.active().unwrap().doc.name.clone(); + assert_eq!(active(&s), "c"); + assert_eq!(s.execute("document.activate", json!({"offset": -2})).unwrap(), json!({"document": 0})); + assert_eq!(active(&s), "a"); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 1})); + assert_eq!(active(&s), "b"); + s.execute("document.activate", json!({"offset": 0})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"document": 2})).unwrap(), json!({"document": 2})); + // A null key counts as omitted. + s.execute("document.activate", json!({"document": null, "offset": -1})).unwrap(); + assert_eq!(active(&s), "b"); + + let err = s.execute("document.activate", json!({"offset": 2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 after the active one, which is 2 of 3"); + let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 before the active one, which is 2 of 3"); + for bad in [ + json!({}), + json!({"offset": null}), + json!({"offset": 1.5}), + json!({"offset": "1"}), + json!({"offset": i64::MAX}), + json!({"offset": i64::MIN}), + json!({"offset": u64::MAX}), + json!({"document": 0, "offset": 1}), + json!({"document": -1}), + ] { + assert!(s.execute("document.activate", bad.clone()).is_err(), "{bad}"); + assert_eq!(active(&s), "b", "{bad}"); + } + // Tabs, not the order the documents were opened in: with "c" dragged to the front, two tabs + // before "b" is "c". + s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + s.execute("document.activate", json!({"offset": -2})).unwrap(); + assert_eq!(active(&s), "c"); +} + /// #2166, measured in Photoshop 25.1: a layer mask and the layer pixels keep separate /// foreground/background pairs, swapped when the edit target changes. #[test] @@ -1580,3 +1762,30 @@ fn merge_down_on_a_group_merges_the_group() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some()); } + +/// `restore_layer_selection` puts a multi-layer selection back, drops gone layers, keeps a clean +/// document clean, and refuses a gone active layer without changing anything. +#[test] +fn restore_layer_selection_puts_back_what_still_exists() { + let mut s = session_with_doc(); + let id = |v: Value| LayerId(v["layer"].as_u64().unwrap()); + let a = id(s.execute("layer.new.layer", json!({})).unwrap()); + let b = id(s.execute("layer.new.layer", json!({})).unwrap()); + let gone = LayerId(u64::MAX); + s.active_mut().unwrap().saved_revision = s.active().unwrap().revision; + s.restore_layer_selection(Some(b), &[a, gone, b], Some(a)).unwrap(); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (Some(b), vec![a, b], Some(a))); + assert_eq!(st.saved_revision, st.revision, "selection is not an edit"); + let snap = |s: &Session| { + let st = s.active().unwrap(); + (st.active_layer, st.selected_layers(), st.layer_anchor, st.revision, st.saved_revision) + }; + let before = snap(&s); + assert!(matches!(s.restore_layer_selection(Some(gone), &[gone], None), Err(EngineError::NoLayer(_)))); + assert_eq!(snap(&s), before, "an error changes nothing"); + s.restore_layer_selection(None, &[a], Some(a)).unwrap(); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected"); + assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document"); +} diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 9baa077608..7893ac8d8f 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -59,7 +59,7 @@ fn duration_param(cmd: &str, p: &Value) -> Result> { } /// The optional `fps` param, rejected unless it's a finite number in `(0, MAX_FPS]` (#1009). -fn fps_param(cmd: &str, p: &Value) -> Result> { +pub(crate) fn fps_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("fps").filter(|v| !v.is_null()) else { return Ok(None) }; v.as_f64() .filter(|f| f.is_finite() && *f > 0.0 && *f <= f64::from(MAX_FPS)) @@ -104,6 +104,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result { t.fps = fps; } if let Some(d) = duration { + // A work area that reached the old end follows the new one, as in + // `video_cmds::grow_duration` (#4170); a shrink is left to `Timeline::clamp`. + if t.work_end >= t.duration { + t.work_end = d; + } t.duration = d; } if let Some(ws) = p.get("workStart").and_then(Value::as_u64) { @@ -245,6 +250,23 @@ mod tests { assert_eq!(current(&mut s), 1); } + /// A longer `duration` moves a work area that reached the old end to the new end, like + /// `video_cmds::grow_duration` (#4170); a narrowed one and an explicit `workEnd` stay put. + #[test] + fn set_props_longer_duration_moves_full_work_area() { + let work = |r: &Value| (r["timeline"]["workStart"].as_u64(), r["timeline"]["workEnd"].as_u64()); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("timeline.create", json!({"duration": 30})).unwrap(); + let r = s.execute("timeline.setProps", json!({"duration": 60})).unwrap(); + assert_eq!(work(&r), (Some(0), Some(60)), "the full work area follows the new end"); + let r = s.execute("timeline.setProps", json!({"duration": 90, "workEnd": 45})).unwrap(); + assert_eq!(work(&r), (Some(0), Some(45)), "an explicit workEnd wins"); + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + let r = s.execute("timeline.setProps", json!({"duration": 120})).unwrap(); + assert_eq!(work(&r), (Some(2), Some(10)), "a narrowed work area stays put"); + } + /// #1009: a timeline stored with `duration` 0 or `u64::MAX` (an old or hand-edited file) steps /// without panicking and lands on a valid frame. #[test] diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..fce8c1be49 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,11 +1,14 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; +use photocraft_algo::transform::{Homography, Interp, WARP_REACH, warp_output_clipped, warp_output_size, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; +#[cfg(test)] +use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; @@ -29,7 +32,7 @@ pub fn moves_whole(layer: &Layer) -> bool { /// content within the selection, or all of it for a layer that [`moves_whole`] (#2630). /// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag. pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { - let content = whole_layer_bounds(layer); + let content = whole_layer_bounds(doc, layer); match &doc.selection { Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()), _ => content, @@ -54,9 +57,12 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { } /// A group moves all its descendants, so its frame must not clip raster children to the selection. -fn whole_layer_bounds(layer: &Layer) -> Rect { +pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect { let content = match &layer.content { - LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)), + // A soft shape (Density below 100 % or a Feather) renders past its path, over the whole + // canvas below 100 %: its frame follows the path and stroke, as in Photoshop (#5846). + LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::soft_shape_frame(sh, doc.pixel_format(), doc.bounds()).unwrap_or(Rect::EMPTY), _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), }; if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content } @@ -257,27 +263,66 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } -/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses -/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not -/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused -/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px -/// on a side before anything could fail. -pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30; +/// The memory (bytes) a single Free Transform warp's result may take: Preferences › Performance › +/// Memory Usage, which [`Session::apply_prefs`] sets, rather than a fixed 2³⁰ px, so a stitched +/// layer of a few gigapixels transforms on a machine that has the memory for it (#7807). Only the +/// size of the result counts, not where it lands, so moving a layer far off the canvas stays +/// allowed as far as the warp reaches ([`WARP_REACH`]). A scale past it is refused rather than +/// clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px on a side +/// before anything could fail. +static BUDGET: AtomicU64 = AtomicU64::new((crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20); -/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one. +/// Set the memory (bytes) one transform's result may take (see [`budget`]). +pub fn set_budget(bytes: u64) { + BUDGET.store(bytes, Ordering::Relaxed); +} + +/// The memory (bytes) one transform's result may take: Memory Usage. +pub fn budget() -> u64 { + BUDGET.load(Ordering::Relaxed) +} + +/// What [`check_budget`] holds a result to: [`budget`]. #[cfg(not(test))] -const BUDGET: u64 = MAX_TRANSFORM_PIXELS; -#[cfg(test)] -const BUDGET: u64 = 1 << 20; +fn active_budget() -> u64 { + budget() +} -/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, -/// before anything is built. `what` names the surface in the message. -pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { - let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; - let (w, ht) = (u64::from(b.width()), u64::from(b.height())); - if w.saturating_mul(ht) > BUDGET { - let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); - return Err(crate::allocation::error("transforming pixels", &detail)); +/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one: +/// 2²⁰ px of the widest format (32-bit CMYK with alpha, 20 MB), so a result within the old 2²⁰ px +/// test limit still fits at any depth. +#[cfg(test)] +fn active_budget() -> u64 { + (1 << 20) * PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true).bytes_per_pixel() as u64 +} + +/// Refuse a warp of the content inside `src` (pixels of `format`) by `h` whose result would take +/// more memory than the budget, or reach past where the warp places pixels (it would be clipped), +/// before anything is built. `what` names the surface in the message, which names the setting or +/// the reach rather than a shortage: nothing has been allocated yet. +pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str, format: PixelFormat) -> Result<()> { + check_budget_with_limit(src, h, what, format, active_budget()) +} + +/// [`check_budget`] against `budget` bytes (the tests price the production budget with it). +fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, format: PixelFormat, budget: u64) -> Result<()> { + let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) }; + // The warp writes each tile into the result as it goes, so the result is what it holds; it + // adds alpha to a format without it. + let bpp = PixelFormat::new(format.mode, format.sample, true).bytes_per_pixel() as u64; + let bytes = w.saturating_mul(ht).saturating_mul(bpp); + if bytes > budget { + let mb = |b: u64| b.div_ceil(1 << 20); + return Err(EngineError::Other(format!( + "too large to transform: the transformed {what} would be {w} x {ht} px ({} MB), more than the {} MB Memory Usage allows (Preferences › Performance); the document was not changed", + mb(bytes), + budget >> 20 + ))); + } + if warp_output_clipped(src, h) { + return Err(EngineError::Other(format!( + "too large to transform: the transformed {what} would reach past ±{WARP_REACH} px, the farthest a transform places pixels; the document was not changed" + ))); } Ok(()) } @@ -297,10 +342,10 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R LayerContent::Text(_) | LayerContent::Shape(_) => {} LayerContent::Smart(sm) => { if let Some(c) = &sm.cache { - check_budget(c.content_bounds(), h, "smart object")?; + check_budget(c.content_bounds(), h, "smart object", c.format())?; } if let Some(m) = &sm.filter_mask { - check_budget(m.surface.content_bounds(), h, "smart filter mask")?; + check_budget(m.surface.content_bounds(), h, "smart filter mask", m.surface.format())?; } } _ => { @@ -310,7 +355,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()), None => surf.content_bounds(), }; - check_budget(src, h, &format!("layer \"{}\"", l.name))?; + check_budget(src, h, &format!("layer \"{}\"", l.name), surf.format())?; } } } @@ -322,7 +367,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R Some(sel) => sel.content_bounds(), None => m.surface.content_bounds(), }; - check_budget(src, h, "layer mask")?; + check_budget(src, h, "layer mask", m.surface.format())?; } } Ok(()) @@ -330,7 +375,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R /// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). -/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. +/// Refused, with `l` untouched, when a result would take more than [`budget`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE) } @@ -375,6 +420,9 @@ fn apply_transform( // With a selection only the selected pixels move, and so only the same region of a linked // mask (#205). Groups, type, shapes and smart objects move whole. let mask_sel = doc_sel.filter(|_| !moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model (as `transform_layer_geometry` does). + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -505,13 +553,124 @@ pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) { } /// Normal-blend `top` over `dst` (both straight alpha, same format). +/// Works tile by tile in parallel on `top`'s allocated tiles: fully opaque tiles share their +/// `Arc` directly with `dst`, and opaque/transparent pixels on border tiles copy encoded +/// bytes without a whole-region `f32` round trip. pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { + use photocraft_color::{SampleType, read_sample}; + use photocraft_geom::TileCoord; + use photocraft_raster::Tile; + use rayon::prelude::*; + use std::sync::Arc; + + let fmt = dst.format(); + let n = fmt.channels(); + if n == 0 || !fmt.alpha || top.format() != fmt { + return; + } + let a = n - 1; + if top.default_pixel().get(a).is_none_or(|&ta| ta <= 0.0) { + let top_tiles: Vec<(TileCoord, Arc)> = top.tiles().map(|(&tc, t)| (tc, t.clone())).collect(); + if top_tiles.is_empty() { + return; + } + let bpp = fmt.bytes_per_pixel(); + let sb = fmt.sample.bytes(); + let alpha_off = bpp - sb; + let dst_empty_default = dst.default_bytes().iter().all(|&b| b == 0); + let dst_ref = &*dst; + let edits: Vec<(TileCoord, Arc)> = top_tiles + .into_par_iter() + .filter_map(|(tc, top_tile)| { + let tb = top_tile.bytes(); + let all_opaque = match fmt.sample { + SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&u8::MAX)), + SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[u8::MAX, u8::MAX])), + SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0), + }; + if all_opaque { + return Some((tc, top_tile)); + } + let all_transparent = match fmt.sample { + SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&0)), + SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[0, 0])), + SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) <= 0.0), + }; + if all_transparent { + return None; + } + if dst_ref.tile(tc).is_none() && dst_empty_default { + let clean_zero = match fmt.sample { + SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) != Some(&0) || px.iter().all(|&b| b == 0)), + SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) != Some(&[0, 0]) || px.iter().all(|&b| b == 0)), + SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)), + }; + if clean_zero { + return Some((tc, top_tile)); + } + } + let mut out_tile = match dst_ref.tile(tc) { + Some(dt) => (**dt).clone(), + None => dst_ref.try_default_tile().ok()?, + }; + let mut tp = [0.0f32; 8]; + let mut dp = [0.0f32; 8]; + for (out_px, top_px) in out_tile.bytes_mut().chunks_exact_mut(bpp).zip(tb.chunks_exact(bpp)) { + match fmt.sample { + SampleType::U8 => { + let ta_b = top_px[alpha_off]; + if ta_b == 0 { + continue; + } + if ta_b == u8::MAX { + out_px.copy_from_slice(top_px); + continue; + } + } + SampleType::U16 => { + if top_px[alpha_off..bpp] == [0, 0] { + continue; + } + if top_px[alpha_off..bpp] == [u8::MAX, u8::MAX] { + out_px.copy_from_slice(top_px); + continue; + } + } + SampleType::F32 => { + let ta = read_sample(top_px, SampleType::F32, a); + if ta <= 0.0 { + continue; + } + if ta == 1.0 { + out_px.copy_from_slice(top_px); + continue; + } + } + } + for c in 0..n { + tp[c] = read_sample(top_px, fmt.sample, c); + dp[c] = read_sample(out_px, fmt.sample, c); + } + let (ta, da) = (tp[a], dp[a]); + let oa = ta + da * (1.0 - ta); + if oa > 0.0 { + for c in 0..a { + dp[c] = (tp[c] * ta + dp[c] * da * (1.0 - ta)) / oa; + } + } + dp[a] = oa; + photocraft_raster::encode_pixel(&fmt, &dp[..n], out_px); + } + Some((tc, Arc::new(out_tile))) + }) + .collect(); + dst.put_tiles(edits); + return; + } let b = top.content_bounds(); if b.is_empty() { return; } - let n = dst.format().channels(); - let a = n - 1; let t = top.read_region(b); let mut d = dst.read_region(b); for (dp, tp) in d.chunks_exact_mut(n).zip(t.chunks_exact(n)) { @@ -655,14 +814,14 @@ fn transform_doc( let sel = doc.selection.clone(); // The selection outline moves too: price it before anything is built. if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { - check_budget(sel.content_bounds(), h, "selection")?; + check_budget(sel.content_bounds(), h, "selection", sel.format())?; } if lone { // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. if !h.is_identity() { let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); - check_budget(src, h, "mask or channel")?; + check_budget(src, h, "mask or channel", surf.format())?; *surf = match &sel { Some(sel) => warp_gray_selected(surf, sel, h, interp)?, None => warp_gray(surf, h, interp)?, @@ -728,12 +887,79 @@ mod tests { s } + /// The content bounds of the active layer's pixels. fn active_bounds(s: &Session) -> Rect { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } - // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + // ---- Memory budget (#3773, #7807). Tests run with a 20 MB budget (2²⁰ px of 32-bit CMYK with + // alpha), so nothing here comes near the real one. ---- + + /// Memory Usage's default, in bytes. + const DEFAULT_BUDGET: u64 = (crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20; + + /// Scales of 10⁷ and past are refused by the real budget too, whatever the axis or sign. + #[test] + fn huge_transform_scales_exceed_the_production_budget_before_clipping() { + let src = Rect::new(0, 0, 24, 16); + for scale in [1e7, 1e28, 1e306] { + for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] { + let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_err(), "{sx} x {sy} must be refused"); + } + } + } + + /// A 240000 px wide stretch, placed far from the canvas but within the warp's reach, fits. + #[test] + fn an_ordinary_transform_remains_within_the_production_budget() { + let src = Rect::new(0, 0, 24, 16); + let h = Homography([10_000.0, 0.0, 800_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok()); + } + + /// The warp places pixels only within ±2²⁰ px ([`WARP_REACH`]): a result that fits Memory + /// Usage but would reach past it, by its size or by where it lands, is refused, not clipped. + #[test] + fn a_result_past_the_warps_reach_is_refused_not_clipped() { + let src = Rect::new(0, 0, 24, 16); + let check = |h: [f64; 9]| check_budget_with_limit(src, &Homography(h), "layer", PixelFormat::RGBA8, DEFAULT_BUDGET); + // 2000016 x 18 px, 138 MB of 8-bit RGBA: from x = 0 it would be cut at 2²⁰; centred it fits. + let err = check([83_334.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).unwrap_err().to_string(); + assert!(err.contains("would reach past ±1048576 px") && err.ends_with("the document was not changed"), "{err}"); + assert!(check([83_334.0, 0.0, -1_000_000.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_ok()); + // A small layer moved past the reach, either way on either axis. + for (dx, dy) in [(1_100_000.0, 0.0), (-1_100_000.0, 0.0), (0.0, 1_100_000.0), (0.0, -1_100_000.0)] { + assert!(check([1.0, 0.0, dx, 0.0, 1.0, dy, 0.0, 0.0, 1.0]).is_err(), "{dx}, {dy}"); + } + // Through the command: nothing moves, nothing is lost. + let mut s = session(); + let st = s.active().unwrap(); + let (before, steps) = (st.doc.clone(), st.history.past_len()); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 2_000_000, 0]})).unwrap_err().to_string(); + assert!(err.contains("the transformed layer \"Layer 1\" would reach past ±1048576 px"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.history.past_len() == steps, "the document is untouched"); + } + + /// #7807: the stitched layer's 90 % and 15°, a 37708 x 35259 px result that the old 2³⁰ px limit + /// refused, fits the default Memory Usage at 8 bits (5072 MB); at 16 bits it needs Memory Usage + /// raised, and the message says so. + #[test] + fn a_gigapixel_layer_transforms_within_memory_usage() { + let src = Rect::new(0, 0, 35_020, 31_171); + let r = [0.0, 0.0, 35_020.0, 31_171.0]; + let h = relative_matrix(&json!({"scaleX": 90, "scaleY": 90, "rotate": 15}), r).unwrap().unwrap(); + assert_eq!(warp_output_size(src, &h), Some((37_708, 35_259)), "the report's result, past the old limit"); + assert!(check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok()); + let err = check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA16, DEFAULT_BUDGET).unwrap_err().to_string(); + assert!(err.contains("(10144 MB), more than the 8192 MB Memory Usage allows (Preferences › Performance)"), "{err}"); + assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA16, 2 * DEFAULT_BUDGET).is_ok()); + // A format without alpha is priced as the warp makes it, with alpha. + let rgb8 = PixelFormat::new(PixelFormat::RGBA8.mode, PixelFormat::RGBA8.sample, false); + assert!(check_budget_with_limit(src, &h, "layer", rgb8, 5071 << 20).is_err()); + } /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. fn column() -> Session { @@ -753,15 +979,22 @@ mod tests { json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"}) } + /// A stretch past the budget is refused with the limit named, and the document, its revision + /// and History stay as they were; within the budget the same stretch applies unclipped. #[test] fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() { let mut s = column(); let st = s.active().unwrap(); let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len()); - // 200000 x 10 px of output: twice the test budget. - let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); - assert!(err.starts_with("not enough memory for transforming pixels"), "{err}"); - assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}"); + // 1000000 x 10 px of 8-bit RGBA output: about twice the test budget. + let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string(); + assert!(err.starts_with("too large to transform: the transformed layer \"Layer 1\""), "{err}"); + assert!( + err.contains("would be 1000002 x 10 px (39 MB), more than the 20 MB Memory Usage allows") && err.ends_with("the document was not changed"), + "{err}" + ); + // Nothing was allocated, so nothing ran short (#7807). + assert!(!err.contains("not enough memory"), "{err}"); let st = s.active().unwrap(); assert!(st.doc == before, "the document is untouched"); assert_eq!((st.revision, st.history.past_len()), (revision, steps)); @@ -779,7 +1012,7 @@ mod tests { let dot = s.active().unwrap().active_layer.unwrap(); s.edit("paint", |doc, _| { doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]); - // Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px. + // Widen the column to 8 px (x 2..10), so it alone needs 800002 x 10 px (31 MB). doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); Ok(()) }) @@ -790,7 +1023,7 @@ mod tests { let st = s.active().unwrap(); assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2)); let (before, revision) = (st.doc.clone(), st.revision); - let k = 5e4; + let k = 1e5; assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); let st = s.active().unwrap(); assert!(st.doc == before && st.revision == revision, "nothing changed"); @@ -802,6 +1035,8 @@ mod tests { assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed"); } + /// Only the result's size is priced: a small layer moved 900000 px away (within the warp's + /// reach) and back keeps every pixel. #[test] fn moving_a_small_layer_far_off_the_canvas_is_within_budget() { let mut s = session(); @@ -811,28 +1046,31 @@ mod tests { assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept"); } + /// Each priced at its own format, as the warp makes it: an 8-bit outline, mask or channel with + /// alpha is 2 bytes a pixel, so a canvas-high column of it stretched 10⁶ times (34 MB) is over + /// the test budget. #[test] fn selections_masks_and_channels_have_the_same_budget() { // The selection outline of a layer transform. let mut s = column(); - s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 16})).unwrap(); let before = s.active().unwrap().doc.clone(); - let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string(); assert!(err.contains("transformed selection would be"), "{err}"); // Transform Selection. - let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string(); + let err = s.execute("select.transformSelection", stretch(1e6)).unwrap_err().to_string(); assert!(err.contains("transformed selection would be"), "{err}"); assert!(s.active().unwrap().doc == before); // A targeted alpha channel. let mut s = column(); s.execute("channel.new", json!({})).unwrap(); s.edit("paint channel", |doc, _| { - doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]); + doc.channels[0].surface.fill_rect(Rect::new(10, 0, 11, 16), &[1.0]); Ok(()) }) .unwrap(); let before = s.active().unwrap().doc.clone(); - let mut p = stretch(2e5); + let mut p = stretch(1e6); p["target"] = json!({"channel": 0}); let err = s.execute("edit.transform", p).unwrap_err().to_string(); assert!(err.contains("transformed mask or channel would be"), "{err}"); @@ -971,6 +1209,57 @@ mod tests { assert!(!active_bounds(&s).is_empty()); } + /// A gradient fill layer whose mask (a selection when it was made) frames the gradient over + /// 10..40, with the cached render a PSD import gives it: exactly the pixels the fill renders. + fn cached_gradient_fill() -> Session { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.edit("cache", |doc, active| { + let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let LayerContent::Fill(f) = &l.content else { panic!("a fill layer") }; + let buf = photocraft_compose::render_fill_content(l, f, canvas, &[]); + let mut surface = Surface::new(fmt); + let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + surface.write_region(canvas, &vals); + l.fill_cache = Some(photocraft_doc::FillCache { fill: f.clone(), surface }); + Ok(()) + }) + .unwrap(); + s + } + + /// How far the composite is from the composite with the fill re-rendered from its model + /// (cache dropped): the largest channel difference. + fn off_from_fresh(s: &Session) -> f32 { + let st = s.active().unwrap(); + let mut fresh: Document = (*st.doc).clone(); + fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) + } + + #[test] + fn transforming_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the cache is the fill's own render: {off}"); + // The mask (and so the gradient's frame) moves 50 px right and doubles in height. + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 2, 50, 0]})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + + #[test] + fn warping_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + #[test] fn type_moves_with_the_move_command() { let mut s = session(); @@ -1423,4 +1712,70 @@ mod tests { assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); } } + + /// #5846: a shape's Density and Feather render past its path (over the whole canvas below + /// 100 %), but its transform frame follows the path, as in Photoshop: the same box as the hard + /// shape for Free Transform, the Move tool's controls and snapping, never the canvas. + #[test] + fn a_soft_shapes_frame_follows_its_path() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 60, 40], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let id = photocraft_doc::LayerId(id); + let boxes = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(id).unwrap(); + (transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id)) + }; + let hard = boxes(&s); + assert_eq!(hard.0, Rect::new(20, 30, 80, 70), "the hard shape's frame"); + s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + { + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let painted = photocraft_compose::bounds::content_bounds(sh.cache.as_ref().unwrap()); + assert_eq!(painted, st.doc.bounds(), "the soft fill renders over the whole canvas"); + } + let soft = boxes(&s); + assert_eq!(soft, hard, "the frame ignores Density and Feather"); + assert_ne!(soft.1, s.active().unwrap().doc.bounds()); + // Feather alone (density 100 %) leaves the frame on the path too, not grown by the blur. + s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 20})).unwrap(); + assert_eq!(boxes(&s), hard); + // Free Transform scales about the path's centre, not the canvas's. + s.execute("edit.transform", json!({"layer": id.0, "scaleX": 200})).unwrap(); + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + assert_eq!((x0.round(), y0.round(), x1.round(), y1.round()), (-10.0, 30.0, 110.0, 70.0)); + assert_eq!((sh.density, sh.feather), (1.0, 20.0), "the transform keeps the soft settings"); + } + + /// A stroked soft shape's frame is the hard shape's rendered frame for every stroke alignment: + /// a centred stroke reaches half its width past the path, not its full width; both stop at + /// the canvas edge. + #[test] + fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() { + for (align, reach, x) in [("center", 5, 50), ("inside", 0, 50), ("outside", 10, 50), ("center", 5, -20)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let stroke = json!({"width": 10, "color": "#ff0000", "align": align, "join": "round"}); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [x, 50, 60, 40], "fill": "#000000", "stroke": stroke})).unwrap()["layer"] + .as_u64() + .unwrap(); + let id = photocraft_doc::LayerId(id); + let boxes = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(id).unwrap(); + (transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id)) + }; + let hard = boxes(&s); + // Hand-computed: the path is x..x + 60 x 50..90, the stroke reaches `reach` px past it. + assert_eq!(hard.0, Rect::new((x - reach).max(0), 50 - reach, x + 60 + reach, 90 + reach), "{align} at {x}: the hard shape's frame"); + s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 8})).unwrap(); + assert_eq!(boxes(&s), hard, "{align} at {x}: Density and Feather leave the frame"); + s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 8})).unwrap(); + assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame"); + } + } } diff --git a/crates/engine/src/trap_cmds.rs b/crates/engine/src/trap_cmds.rs index 03d899472c..6bd99200ac 100644 --- a/crates/engine/src/trap_cmds.rs +++ b/crates/engine/src/trap_cmds.rs @@ -24,6 +24,7 @@ fn trap(s: &mut Session, p: &Value) -> Result { } s.edit("Trap", |doc, active| { let id = active.ok_or(EngineError::NoDocument)?; + crate::commands::check_pixels_unlocked(doc, id)?; let bounds = doc.bounds(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Raster(surf) = &mut l.content else { @@ -63,11 +64,8 @@ mod tests { use super::*; use photocraft_geom::Rect; - #[test] - fn traps_a_cmyk_document() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); - // Paint cyan | magenta halves on the Background. + /// Paints cyan | magenta halves on the active layer. + fn paint_halves(s: &mut Session) { s.edit("paint", |doc, active| { let id = active.unwrap(); let fmt = doc.pixel_format(); @@ -93,6 +91,13 @@ mod tests { Ok(()) }) .unwrap(); + } + + #[test] + fn traps_a_cmyk_document() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + paint_halves(&mut s); let r = s.execute("image.trap", json!({"width": 1})).unwrap(); assert_eq!(r["trapped"], true); @@ -109,6 +114,35 @@ mod tests { assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Trap")); } + #[test] + fn refuses_locked_layers() { + // Lock All or the pixel lock, on the layer itself or on its group: Trap refuses, leaving the + // pixels and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_halves(&mut s); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the layer ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().surface().cloned(); + let r = s.execute("image.trap", json!({"width": 1})); + assert!(r.is_err(), "trapped a layer locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(layer).unwrap().surface().cloned(), before, "{lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{lock} group: {on_group}: no history step"); + } + } + } + #[test] fn rejects_non_cmyk() { let mut s = Session::new(); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..820338bf2d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -8,9 +8,10 @@ use std::sync::Arc; -use photocraft_color::Color; +use photocraft_color::{Color, PixelFormat}; use photocraft_doc::text::{ - AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, + AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, + TextRun, TextShape, }; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer}; use serde_json::{Value, json}; @@ -34,14 +35,14 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { } fn f32p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64).map(|v| v as f32) + p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } fn color(v: &Value) -> Option { match v { Value::Array(a) if a.len() >= 3 => { let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) + c.iter().take(4).all(|v| v.is_finite()).then(|| Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) } Value::String(s) => { let s = s.trim_start_matches('#'); @@ -89,6 +90,8 @@ pub const TYPE_SIZE_MIN_PT: f64 = 0.1; pub const TYPE_SIZE_MAX_PT: f64 = 1296.0; pub const TRACKING_MIN: f64 = -1000.0; pub const TRACKING_MAX: f64 = 10_000.0; +/// Photoshop's horizontal and vertical scale range, in percent. +pub const SCALE_MAX_PERCENT: f64 = 1000.0; /// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or /// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`). @@ -104,7 +107,9 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> { } } -/// Validates text metrics that can otherwise exceed the rasterizer's supported range. +/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise +/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold, +/// and the superscript/subscript `baseline`, so a typo is an error rather than a reset to Normal. pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { if let Some(v) = p.get("size") { match v.as_f64() { @@ -118,6 +123,35 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { _ => return Err(format!("tracking must be between {TRACKING_MIN} and {TRACKING_MAX} (1/1000 em)")), } } + if let Some(v) = p.get("leading") { + let (min, max) = (f64::from(LEADING_MIN_PT), f64::from(LEADING_MAX_PT)); + match v { + Value::Null => {} + Value::String(a) if a == "auto" => {} + Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && (min..=max).contains(&x)) => {} + _ => return Err(format!("leading must be between {min} and {max} pt, or \"auto\"")), + } + } + if let Some(v) = p.get("baselineShift") { + let max = f64::from(BASELINE_SHIFT_MAX_PT); + match v.as_f64() { + Some(shift) if shift.is_finite() && shift.abs() <= max => {} + _ => return Err(format!("baselineShift must be between -{max} and {max} pt")), + } + } + if let Some(v) = p.get("baseline").filter(|v| !v.is_null()) + && !matches!(v.as_str(), Some("normal" | "superscript" | "subscript")) + { + return Err("baseline must be \"normal\", \"superscript\" or \"subscript\"".into()); + } + for key in ["horizontalScale", "verticalScale"] { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(scale) if scale.is_finite() && (0.0..=SCALE_MAX_PERCENT).contains(&scale) => {} + _ => return Err(format!("{key} must be between 0 and {SCALE_MAX_PERCENT} %")), + } + } + } Ok(()) } @@ -216,6 +250,19 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) { s.font_family = v.to_string(); s.postscript_name = None; + // Photoshop accepts either a family name or a face's PostScript name in the font field. + // Resolve only exact PostScript matches here so an unknown family remains untouched. + let mut text = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + if !text.fonts.has_family(v) { + let resolved = text.fonts.resolve_postscript(v); + if resolved.exact { + s.variations.retain(|f| f.axis != "wght"); + s.font_family = resolved.family; + s.weight = resolved.weight; + s.italic = resolved.italic; + s.postscript_name = Some(v.to_string()); + } + } hit(true); } if let Some(v) = p.get("fontStyle").and_then(Value::as_str) { @@ -336,6 +383,19 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { }; hit(true); } + if let Some(v) = p.get("baseline").and_then(Value::as_str) { + let baseline = match v { + "superscript" => Some(FontBaseline::Superscript), + "subscript" => Some(FontBaseline::Subscript), + "normal" => Some(FontBaseline::Normal), + _ => None, + }; + // An unknown value keeps the style (the commands reject it in `check_size_tracking`). + if let Some(b) = baseline { + s.baseline = b; + hit(true); + } + } 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); @@ -477,8 +537,9 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } -/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the -/// character before it (or after it at the start). +/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its +/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting +/// at a caret takes the style of the character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); @@ -494,7 +555,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) + .position(|(s, e)| { + if a == 0 { + e > 0 || s == 0 + } else if a < b { + a >= s && a < e + } else { + a > s && a <= e + } + }) .unwrap_or(0) }; for (i, (len, st)) in runs.into_iter().enumerate() { @@ -534,9 +603,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { /// Re-renders the layer's pixels and regenerates its PSD `TySh` data. pub fn refresh(doc: &Document, t: &mut TextLayer) { - let dpi = doc.resolution_dpi; + refresh_at(doc.resolution_dpi, doc.pixel_format(), t); +} + +/// [`refresh`] given only the two things it reads from the document: its resolution and its +/// pixel format. A caller holding the layer mutably reads them first instead of cloning the whole +/// document to pass it, once per keystroke and pointer move while typing (#7092). +pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - let (layout, r) = eng.render(t, dpi, doc.pixel_format()); + let (layout, r) = eng.render(t, dpi, format); t.cache = Some(r.surface); t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } @@ -553,11 +628,12 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. -fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { +/// `f` gets the document's resolution (dpi). +fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { @@ -565,8 +641,8 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let mut rename = None; - let r = f(t, &snapshot, &mut rename)?; - refresh(&snapshot, t); + let r = f(t, dpi, &mut rename)?; + refresh_at(dpi, format, t); // Only this layer's pixels changed: the canvas recomposites their old and new area // instead of the whole document (#124). Unknown old pixels mean a full refresh. let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b)); @@ -628,15 +704,19 @@ pub(crate) fn with_text_targets( mut f: impl FnMut(&mut TextLayer, (usize, usize)) -> Result<()>, ) -> Result> { let ids = format_targets(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + for id in &ids { + check_text_unlocked(doc, *id)?; + } let damage = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let mut damage = Some(photocraft_geom::Rect::EMPTY); for id in &ids { let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) else { return Err(EngineError::NoLayer(*id)) }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let range = if p.get("layers").is_none() || p.get("layer").is_some() { range_param(&t.text, p) } else { (0, t.text.len()) }; f(t, range)?; - refresh(&snapshot, t); + refresh_at(dpi, format, t); // An unknown old/new cache retains Session.edit's full-refresh fallback. damage = damage.zip(before).zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|((all, a), b)| all.union(&a).union(&b)); } @@ -743,6 +823,7 @@ fn info(s: &Session, p: &Value) -> Result { /// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type /// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`. pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result { + crate::commands::check_f32_values(cmd, p)?; let orientation = match p.get("orientation") { None => Orientation::Horizontal, Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, @@ -785,7 +866,7 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Vec { vec![ @@ -856,7 +937,7 @@ pub fn specs() -> Vec { } }; let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" }; - with_text_layer(s, p, label, |t, doc, rename| { + with_text_layer(s, p, label, |t, dpi, rename| { if let Some((at, by)) = kern_pair { // Photoshop's Alt+←/→: the pair before the caret becomes manually kerned, // starting from what it shows now (its metrics/optical or manual value). @@ -868,7 +949,7 @@ pub fn specs() -> Vec { let b = byte_at(&t.text, at); let now = { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - eng.pair_kerning(t, doc.resolution_dpi, a) + eng.pair_kerning(t, dpi, a) } .ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?; let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32; @@ -984,16 +1065,16 @@ pub fn specs() -> Vec { run: |s, p| { let id = layer_id(s, p)?; s.edit("Rasterize Type", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is not a type layer", id.0))); }; if t.cache.is_none() { - refresh(&snapshot, t); + refresh_at(dpi, format, t); } - let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(snapshot.pixel_format())); - let surface = if surface.format() == snapshot.pixel_format() { surface } else { surface.convert(snapshot.pixel_format()) }; + let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(format)); + let surface = if surface.format() == format { surface } else { surface.convert(format) }; l.content = LayerContent::Raster(surface); l.psd_blocks.retain(|(k, _)| k != b"TySh"); Ok(()) @@ -1384,6 +1465,35 @@ mod tests { assert_eq!(s.active().unwrap().history.past_len(), history); } + #[test] + fn all_locked_type_layer_rejects_formatting_without_history() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 20, "text": "a", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("type.create", json!({"x": 20, "y": 60, "text": "b", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let before = text_layer(&s, id); + let history = s.active().unwrap().history.past_len(); + + for (cmd, p) in [ + ("type.setStyle", json!({"layer": id, "size": 40, "fauxBold": true})), + ("type.setStyle", json!({"layers": [other, id], "size": 40})), + ("type.warpText", json!({"layer": id, "style": "arc"})), + ("type.antiAlias.none", json!({"layer": id})), + ("type.orientation.vertical", json!({"layer": id})), + ("type.openType.swash", json!({"layer": id})), + ] { + let result = s.execute(cmd, p); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + } + assert_eq!(text_layer(&s, id), before); + assert_eq!(text_layer(&s, other).size_pt, 12.0); + assert_eq!(s.active().unwrap().history.past_len(), history); + + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "size": 40})).unwrap(); + assert_eq!(text_layer(&s, id).size_pt, 40.0); + } + #[test] fn trailing_empty_paragraph_styles_without_changing_previous_lines() { let mut s = session(); @@ -1519,6 +1629,42 @@ mod tests { assert_eq!(t.runs[0].style.size_pt, 40.0); } + /// Replacing a selected smaller second line (by typing or pasting) keeps its size, not the + /// first line's, whose line break precedes the selection. + #[test] + fn replacing_a_selection_keeps_its_first_characters_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "BIG\nsmall", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 4, "end": 9, "size": 12}]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 9, "text": "AIR"}})).unwrap(); + let sizes = |s: &Session| text_layer(s, id).char_runs().iter().map(|r| (r.len, r.style.size_pt)).collect::>(); + assert_eq!(text_layer(&s, id).text, "BIG\nAIR"); + assert_eq!(sizes(&s), [(4, 40.0), (3, 12.0)]); + // A mixed selection takes its first character's style. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 6, "text": "xy"}})).unwrap(); + assert_eq!(sizes(&s), [(4, 40.0), (1, 12.0)]); + // Inserting at a caret still follows the character before it. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 4, "text": "!"}})).unwrap(); + assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]); + } + + /// A misspelt `baseline` is an error and keeps superscript, instead of resetting it to Normal. + #[test] + fn unknown_baseline_is_rejected_and_keeps_the_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "x2", "size": 30})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [1, 2], "baseline": "superscript"})).unwrap(); + let before = text_layer(&s, id).runs; + for b in [json!("superscipt"), json!("Superscript"), json!(1)] { + assert!(matches!(s.execute("type.setStyle", json!({"layer": id, "baseline": b})), Err(EngineError::BadParams { .. })), "{b}"); + assert!(s.execute("type.edit", json!({"layer": id, "baseline": b})).is_err(), "{b}"); + assert!(s.execute("type.create", json!({"x": 0, "y": 10, "text": "a", "baseline": b})).is_err(), "{b}"); + } + assert_eq!(text_layer(&s, id).runs, before); + s.execute("type.setStyle", json!({"layer": id, "baseline": "normal"})).unwrap(); + assert!(text_layer(&s, id).char_runs().iter().all(|r| r.style.baseline == FontBaseline::Normal)); + } + fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> { let t = text_layer(s, id); let mut out = Vec::new(); diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index efa2d791cc..fd54db1fed 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -13,9 +13,10 @@ use photocraft_geom::Point; use photocraft_text::render::PathEl; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, layer_param}; +use crate::commands::{CommandSpec, explicit_layer, layer_param}; use crate::type_cmds::{ - check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range, + with_text_targets, }; use crate::{EngineError, Result, Session}; @@ -68,19 +69,20 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { // ---------- helpers ---------- -/// Edits a type layer as one history step and re-renders it. -fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { +/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution +/// (dpi). +fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { let id = layer_param(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name()))); }; - let r = f(t, &snapshot)?; - refresh(&snapshot, t); + let r = f(t, dpi)?; + refresh_at(dpi, format, t); let name = auto_named.then(|| layer_name(&t.text)); if let Some(n) = name { l.name = n; @@ -90,7 +92,11 @@ fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex } fn layout(doc: &Document, t: &TextLayer) -> photocraft_text::TextLayout { - photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, doc.resolution_dpi) + layout_at(doc.resolution_dpi, t) +} + +fn layout_at(dpi: f32, t: &TextLayer) -> photocraft_text::TextLayout { + photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, dpi) } /// `m ∘ translate(tx, ty)`. @@ -144,11 +150,11 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result // ---------- point ⇄ paragraph ---------- fn to_paragraph(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| { - let l = layout(doc, t); - let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0; + let r = with_text(s, p, "Convert to Paragraph Text", |t, dpi| { + let l = layout_at(dpi, t); + let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * dpi / 72.0; let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default(); - let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0; + let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * dpi / 72.0; if l.vertical { // Line space: u along the columns, v across them (first column centred on v = 0). let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]); @@ -178,7 +184,7 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { probe.shape = TextShape::Box { x, y: y0, width, height }; // Box text puts the first baseline at the box top plus the ascender height: measure it and // shift the box so that baseline stays at the point-text anchor (y = 0). - let b0 = layout(doc, &probe).lines.first().map_or(0.0, |ln| ln.baseline); + let b0 = layout_at(dpi, &probe).lines.first().map_or(0.0, |ln| ln.baseline); let y = y0 - b0; t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height }; t.transform = then_translate(&t.transform, f64::from(x), f64::from(y)); @@ -188,9 +194,9 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { } fn to_point(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Point Text", |t, doc| { + let r = with_text(s, p, "Convert to Point Text", |t, dpi| { let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) }; - let l = layout(doc, t); + let l = layout_at(dpi, t); // Vertical type: columns run along the box height. let (x, width) = if l.vertical { (y, height) } else { (x, width) }; let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline); @@ -368,13 +374,24 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => false, Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, }; + // A document point: the type starts where the path passes nearest it (the Type tool's click). + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + Some(xy.filter(|(x, y)| x.is_finite() && y.is_finite()).ok_or_else(|| bad("\"at\" must be [x, y] in document pixels"))?) + } + }; + if at.is_some() && p.get("start").is_some_and(|v| !v.is_null()) { + return Err(bad("pass \"start\" or \"at\", not both")); + } let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; if doc.effective_locks(id).all { return Err(EngineError::Other("the type layer is locked".into())); } let sub = path_source(doc, p)?; - with_text(s, p, "Type on a Path", |t, _| { + let start = with_text(s, p, "Type on a Path", |t, _| { // The path lives in document space; the type layer keeps it in its own text space, so // the type lands on the path where it is drawn. let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; @@ -384,8 +401,15 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { if !path.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())); } + let start = match at { + None => start, + Some((x, y)) => { + let q = map(Point::new(x, y)); + photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32 + } + }; t.shape = TextShape::Path { path, start, flip }; - Ok(()) + Ok(start) })?; Ok(json!({"layer": id.0, "start": start, "flip": flip})) } @@ -477,7 +501,10 @@ fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result Result { - if has_text(s).is_ok() && p.get("new").and_then(Value::as_bool) != Some(true) { + // Into the layer `with_text` edits: an explicit "layer" (an error unless it is a type layer), + // else the active layer when it is a type layer. + let new = p.get("new").and_then(Value::as_bool) == Some(true); + if !new && (explicit_layer(s, p)?.is_some() || has_text(s).is_ok()) { let caret = with_text(s, p, "Paste Lorem Ipsum", |t, _| { let at = p.get("at").and_then(Value::as_u64).map_or(t.text.len(), |c| byte_index(&t.text, c as usize)); replace_text(t, at, at, LOREM_IPSUM); @@ -496,11 +523,11 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result { fn update_all(s: &mut Session) -> Result { let mut n = 0; s.edit("Update All Text Layers", |doc, _| { - let snapshot = doc.clone(); - let ids: Vec = snapshot.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); for id in ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(id) { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } @@ -524,16 +551,39 @@ fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_do true } -/// Font families used by type layers that the font database cannot supply. +/// The document's type layers, inside groups too. +fn type_layers(doc: &Document) -> Vec<&TextLayer> { + doc.walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t), + _ => None, + }) + .collect() +} + +/// Whether `doc` has a type layer. Only then does checking its fonts need the shared text engine, +/// whose first use waits for the installed-font scan started at launch: minutes on some machines +/// (#7332). +pub fn has_type_layers(doc: &Document) -> bool { + doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Text(_))) +} + +/// Font families used by type layers that the font database cannot supply. A document without +/// type returns at once, without touching the shared text engine: the shell asks this on the UI +/// thread for every file it opens, and waiting there for the font scan froze the window on the +/// first open after launch (#7332). pub fn missing_fonts(doc: &Document) -> Vec { + let layers = type_layers(doc); + if layers.is_empty() { + return Vec::new(); + } let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let mut out: Vec = Vec::new(); - for (_, _, l) in doc.walk() { - if let LayerContent::Text(t) = &l.content { - for r in t.char_runs() { - if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) { - out.push(r.style.font_family); - } + for t in layers { + for r in t.char_runs() { + if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) { + out.push(r.style.font_family); } } } @@ -582,7 +632,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo } let mut n = 0; s.edit("Replace Missing Fonts", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for id in &ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(*id) { let mut changed = false; @@ -603,7 +653,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo at += r.len; } if changed { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } @@ -696,7 +746,7 @@ pub fn specs() -> Vec { "type.attachToPath", "Type on a Path", &[], - r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"at":[x,y]? (instead of "start": begin where the path passes nearest this document point, as a Type tool click does),"flip":bool=false (run along the path the other way, on its other side)}"##, has_text, attach_to_path ), diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 7386927bb7..d1bc3c39f1 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -314,6 +314,34 @@ fn type_attaches_to_a_path_and_releases_it() { assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); } +/// 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] +fn type_attaches_to_a_path_at_a_document_point() { + let mut s = session(8); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (290.0, 60.0)])])); + Ok(()) + }) + .unwrap(); + for (align, centred) in [("left", false), ("center", true)] { + let r = s.execute("type.create", json!({"x": 150, "y": 60, "text": "Path", "size": 24, "align": align, "color": "#000000"})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert!(s.execute("type.attachToPath", json!({"at": [150, "y"]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150, 60], "start": 3})).is_err()); + let r = s.execute("type.attachToPath", json!({"at": [150, 64]})).unwrap(); + assert!((r["start"].as_f64().unwrap() - 140.0).abs() < 0.5, "{r}"); + let b = ink(&s, id); + assert!(b.y1 <= 61, "the type sits on the path: {b:?}"); + if centred { + assert!(b.x0 < 145 && b.x1 > 155, "centred on the click: {b:?}"); + } else { + assert!((148..=156).contains(&b.x0), "starts at the click: {b:?}"); + } + } +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. @@ -329,6 +357,27 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { } } +#[test] +fn paste_lorem_ipsum_inserts_into_the_layer_param_not_the_active_layer() { + // #5659: with a pixel layer active, an explicit type "layer" got a new text box instead. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.new.layer", json!({})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + assert_ne!(pixel, id); + let count = s.active().unwrap().doc.walk().len(); + let r = s.execute("type.pasteLoremIpsum", json!({"layer": id.0, "at": 0})).unwrap(); + assert!(text(&s, id).text.starts_with(LOREM_IPSUM), "{}", text(&s, id).text); + assert!(text(&s, id).text.ends_with("Hi")); + assert_eq!(r["caret"], LOREM_IPSUM.chars().count()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + // An explicit layer that is not a type layer is an error, not a new layer. + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("type.pasteLoremIpsum", json!({"layer": pixel.0})).is_err()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + /// A type layer stored with a family label that is not installed, as a PSD keeps the family it /// guessed from a PostScript name whose face was absent when the file was opened. fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) { @@ -399,3 +448,42 @@ fn exact_postscript_face_outside_the_guessed_family_is_not_missing() { assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family)); assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true)); } + +/// #5044: PostScript names whose family has its space elsewhere (`BMDoHyeon`, family +/// "BM DoHyeon") or an underscore style suffix (`Daum_SemiBold`). Guessing splits them as +/// "BM Do Hyeon" and "Daum_Semi Bold", neither installed; the exact installed face resolves them. +#[test] +fn camel_case_and_underscore_postscript_names_resolve_to_their_installed_faces() { + let mut fonts = photocraft_text::FontDb::new(); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "BM DoHyeon", "BMDoHyeon")); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Daum", "Daum_SemiBold")); + for (ps, family) in [("BMDoHyeon", "BM DoHyeon"), ("Daum_SemiBold", "Daum")] { + let guess = photocraft_text::fonts::guess_from_postscript(ps).family; + assert!(!fonts.has_family(&guess), "{ps}: the guess {guess:?} is not an installed family"); + let r = fonts.resolve_postscript(ps); + assert_eq!((r.family.as_str(), r.exact), (family, true), "{ps}"); + let style = photocraft_doc::text::CharStyle { font_family: guess.clone(), postscript_name: Some(ps.into()), ..Default::default() }; + assert!(!style_font_missing(&mut fonts, &style), "{ps} stored as {guess:?} is not missing"); + } +} + +/// #7332: the shell asks for an opened file's missing fonts on the UI thread. A document without +/// type must answer without the shared text engine, whose first use waits for the installed-font +/// scan (minutes on some machines). The engine is held locked meanwhile, which used to block it. +#[test] +fn a_document_without_type_never_waits_for_the_text_engine() { + let s = session(8); + let doc = (*s.active().unwrap().doc).clone(); + assert!(!has_type_layers(&doc)); + let held = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(missing_font_warnings(&doc)); + }); + let got = rx.recv_timeout(std::time::Duration::from_secs(10)); + drop(held); + assert_eq!(got, Ok(Vec::::new()), "a document without type waited for the text engine"); + let mut s = session(8); + text_layer(&mut s, "type"); + assert!(has_type_layers(&s.active().unwrap().doc)); +} diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index 12a63a364c..d42500c722 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh_at, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -27,8 +27,30 @@ fn byte_at(text: &str, ci: usize) -> usize { text.char_indices().nth(ci).map_or(text.len(), |(b, _)| b) } -fn char_at(text: &str, bi: usize) -> usize { - text[..bi.min(text.len())].chars().count() +/// Maps ascending byte offsets to character offsets in a single forward pass. Calling +/// `char_at` per offset rescans the text prefix each time, which is quadratic when a layer +/// has many misspellings; this walks the text once for the whole batch. `offsets` must be +/// sorted ascending (misspelling start/end pairs are, since words are found left to right). +fn char_offsets(text: &str, offsets: &[usize]) -> Vec { + // (byte offset, char index) for each char boundary, plus the end-of-text boundary. + let mut boundaries = text.char_indices().map(|(b, _)| b); + let mut char_index = 0usize; + let mut current = boundaries.next(); + let mut result = Vec::with_capacity(offsets.len()); + for &target in offsets { + let clamped = target.min(text.len()); + // Advance to the first boundary at or past `clamped`; byte offsets from char + // boundaries land exactly on one, so the char index there is the answer. + while let Some(b) = current { + if b >= clamped { + break; + } + char_index += 1; + current = boundaries.next(); + } + result.push(char_index); + } + result } /// Type layers top to bottom (Layers panel order), skipping fully locked ones. @@ -76,10 +98,17 @@ fn list(s: &Session, p: &Value) -> Result { let mut out = Vec::new(); for id in layers_param(s, p) { let Some(t) = text(&st.doc, id) else { continue }; - for m in dict.misspellings(t, &ok) { - out.push( - json!({ "layer": id.0, "start": char_at(t, m.start), "end": char_at(t, m.end), "word": m.word, "suggestions": dict.suggest(&m.word, max) }), - ); + let found = dict.misspellings(t, &ok); + // Convert every start/end byte offset to a character offset in one pass over the + // text, instead of rescanning the prefix with char_at twice per misspelling. + let mut offsets = Vec::with_capacity(found.len() * 2); + for m in &found { + offsets.push(m.start); + offsets.push(m.end); + } + let chars = char_offsets(t, &offsets); + for (i, m) in found.iter().enumerate() { + out.push(json!({ "layer": id.0, "start": chars[i * 2], "end": chars[i * 2 + 1], "word": m.word, "suggestions": dict.suggest(&m.word, max) })); } } Ok(json!({ "misspellings": out, "count": out.len() })) @@ -102,10 +131,10 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("the text at {start}..{end} is {:?}, not {w:?}", &t[a..b]))); } s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, a, b, &to); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; @@ -129,13 +158,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result { let count: usize = plan.iter().map(|(_, h)| h.len()).sum(); if count > 0 { s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for (id, hits) in &plan { if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) { for (a, b) in hits.iter().rev() { replace_text(t, *a, *b, &to); } - refresh(&snapshot, t); + refresh_at(dpi, format, t); } } Ok(()) @@ -215,10 +244,10 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let (ba, bb) = (byte_at(t, a), byte_at(t, b)); let label = p.get("label").and_then(Value::as_str).unwrap_or("Insert Glyph").to_string(); s.edit(&label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, ba, bb, &new); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; @@ -263,6 +292,17 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn char_offsets_matches_per_offset_counting() { + // Multibyte text so byte offsets and char offsets differ. The batch result must + // equal counting chars in each prefix individually, including the end-of-text offset. + let text = "café naïve ☃ résumé"; + let targets: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); + let got = char_offsets(text, &targets); + let want: Vec = targets.iter().map(|&b| text[..b.min(text.len())].chars().count()).collect(); + assert_eq!(got, want); + } + fn session(text: &str) -> (Session, u64) { let mut s = Session::new(); s.execute("file.new", json!({"width": 300, "height": 100, "background": "white"})).unwrap(); diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 797dfe7f8a..4cbb2092dc 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -17,7 +17,7 @@ use photocraft_doc::{Document, LayerContent, LayerId, TextLayer, TextStyles}; use serde_json::{Map, Value, json}; use crate::commands::CommandSpec; -use crate::type_cmds::{apply_char_props, apply_para_props, refresh}; +use crate::type_cmds::{apply_char_props, apply_para_props, refresh_at}; use crate::{EngineError, Result, Session}; const FAMILY: &str = photocraft_text::fonts::DEFAULT_FAMILY; @@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result> { /// A character style with every field changed from the default, to see which fields a set of /// Character-panel keys touches. fn perturbed_char() -> CharStyle { - use photocraft_doc::text::{Caps, FontFeature, FontVariation, Kerning}; + use photocraft_doc::text::{Caps, FontBaseline, FontFeature, FontVariation, Kerning}; CharStyle { font_family: "\u{1}".into(), font_style: "\u{1}".into(), @@ -105,6 +105,7 @@ fn perturbed_char() -> CharStyle { kerning: Kerning::Off, kern: -12345.0, caps: Caps::AllCaps, + baseline: FontBaseline::Subscript, ligatures: false, discretionary_ligatures: true, features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }], @@ -291,7 +292,7 @@ fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op: fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize { let old = doc.text_styles.clone(); doc.text_styles = new.clone(); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); let mut changed = 0; for id in ids { @@ -302,7 +303,7 @@ fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) None => restyle(t, &old, new, Some((0, 0)), Op::default()), }; if did { - refresh(&snapshot, t); + refresh_at(dpi, format, t); changed += 1; } } @@ -516,7 +517,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } else { doc.text_styles.character.retain(|d| d.id != id); } - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); for lid in ids { let Some(LayerContent::Text(t)) = doc.layer_mut(lid).map(|l| &mut l.content) else { continue }; @@ -537,7 +538,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } } if hit { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index f42b744844..0560a9d593 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -165,7 +165,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( } } (VarKind::TextReplacement, VarValue::Text(text)) => { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(l) = doc.layer_mut(def.layer) && let LayerContent::Text(t) = &mut l.content { @@ -180,7 +180,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( t.sync_summary(); // The compositor draws a type layer from its cache: clearing it left the // layer blank until some other edit re-rendered it (#990). - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } } (VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => { @@ -199,7 +199,9 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM return Ok(()); } let fmt = doc.pixel_format(); - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + // The path is stored in the document's data sets. + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the image", path, why))?; + let bytes = std::fs::read(&file).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; let src = crate::file_cmds::import(path, &bytes)?; let img = crate::file_cmds::flattened(&src, fmt); let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64); @@ -284,6 +286,8 @@ fn import_data_sets(s: &mut Session, p: &Value) -> Result { /// file beneath its read root and hands the text over (#3216). pub fn import_data_sets_text(s: &mut Session, text: &str, p: &Value) -> Result { let delim = p.get("delimiter").and_then(Value::as_str).and_then(|d| d.bytes().next()).unwrap_or(b','); + // Excel's "CSV UTF-8" starts with a byte order mark that is not part of the first header cell. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); let mut records = csv_records(text).filter(|r| !r.trim().is_empty()); let header: Vec = records.next().map(|h| split_csv(h, delim)).unwrap_or_default(); diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index c2a30cab68..8a0c591e71 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -136,6 +136,38 @@ fn csv_import_and_apply() { assert_eq!(text_of(&s, title), "On Sale"); } +#[test] +fn csv_import_ignores_utf8_bom() { + let dir = tmp("csv-bom"); + let csv = format!("{dir}/sets.csv"); + + // BOM before the `DataSet` name column: sets keep their names. + let (mut s, _p, badge, title) = session(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); + std::fs::write(&csv, "\u{feff}DataSet,showBadge,headline\nrow-on,true,On Sale\n").unwrap(); + let r = s.execute("file.import.variableDataSets", json!({"path": csv})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"name": "row-on"})).unwrap(); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "On Sale"); + + // BOM before a variable column (no name column): the variable is still imported. + let (mut s, _p, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [{"name": "headline", "layer": title.0, "type": "textReplacement"}]})).unwrap(); + let _ = badge; + let r = import_data_sets_text(&mut s, "\u{feff}headline\nHello\n", &json!({})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"index": 0})).unwrap(); + assert_eq!(text_of(&s, title), "Hello"); +} + #[test] fn csv_fields_preserve_unicode_and_quoting() { for (line, delimiter, expected) in [ diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..4a94c1e9ab 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -412,13 +412,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) } "ellipse" => LiveShape::Ellipse { rect: rect()? }, "polygon" | "star" => { - let (ps, pr) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }), + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } } + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } } "line" => { let (pf, pto, pw) = match prev { @@ -481,10 +487,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { vm.path = vm.path.transform(&a); } match &mut l.content { - LayerContent::Shape(sh) => { - transform_shape(sh, &a); - refresh_shape(doc, sh); - } + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), LayerContent::Group(g) => { for c in &mut g.children { translate_vectors(doc, c, dx, dy); @@ -494,6 +497,30 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { } } +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) + }); + let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); + let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { + (x0 - stroke_pad) > canvas.x0 as f64 + && (y0 - stroke_pad) > canvas.y0 as f64 + && (x1 + stroke_pad) < canvas.x1 as f64 + && (y1 + stroke_pad) < canvas.y1 as f64 + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + /// Applies an affine transform to a shape's path, keeping the live shape when the transform /// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { @@ -510,7 +537,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } } LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } LiveShape::Line { from, to, weight } => { // Live-shape edits regenerate the line's filled bar from its parameters. // Keep the weight in sync with a positive scale of the path, or an @@ -704,7 +733,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result { sh.live = None; sh.psd_raw = None; } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"]; + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; if geom_keys.iter().any(|k| p.get(*k).is_some()) { let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; @@ -1091,9 +1120,25 @@ fn path_rename(s: &mut Session, p: &Value) -> Result { fn path_to_selection(s: &mut Session, p: &Value) -> Result { let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let feather = f64p(p, "feather").unwrap_or(0.0) as f32; - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let mode = SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace")); + // Validate before rasterizing or touching selection history. An unchecked feather value + // could silently become zero (negative/string), or expand into an excessive blur. + let feather = match p.get("feather") { + None => 0.0, + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, + }; + let aa = match p.get("antiAlias") { + None => true, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), + }; + let mode = match p.get("mode") { + None => SelectionMode::Replace, + Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), + _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), + }; let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); if !aa { @@ -1369,11 +1414,15 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { fn vector_mask_info(s: &Session, id: LayerId) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - Ok(match &l.vector_mask { - Some(v) => { + Ok(match (&l.vector_mask, &l.content) { + (Some(v), _) => { json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) } - None => json!({ "layer": id.0, "vectorMask": null }), + // A shape layer's path is its vector mask. + (None, LayerContent::Shape(sh)) => { + json!({ "layer": id.0, "path": path_json(&sh.path), "enabled": true, "linked": true, "shape": true, "density": sh.density * 100.0, "feather": sh.feather }) + } + (None, _) => json!({ "layer": id.0, "vectorMask": null }), }) } @@ -1405,7 +1454,8 @@ fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result if locked { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } - l.vector_mask = Some(VectorMask::new(path)); + crate::extra_cmds::background_to_layer(doc, id); + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.vector_mask = Some(VectorMask::new(path)); Ok(()) })?; vector_mask_info(s, id) @@ -1425,6 +1475,35 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) }) .transpose()?; + let density = p + .get("density") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=100.0).contains(n)) + .map(|n| (n / 100.0) as f32) + .ok_or_else(|| bad(CMD, "`density` must be a finite number within 0..100 (%)")) + }) + .transpose()?; + // A shape layer's path is its vector mask (Photoshop's Properties › Masks): Density and + // Feather soften its fill; the path itself is edited with the shape and path commands. + if s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)) && l.vector_mask.is_none()) { + if let Some(k) = ["path", "enabled", "linked", "invert"].into_iter().find(|k| p.get(*k).is_some()) { + return Err(bad(CMD, format!("`{k}`: a shape layer's vector mask is its path; only `density` and `feather` apply"))); + } + if density.is_none() && feather.is_none() { + return vector_mask_info(s, id); + } + with_shape(s, id, "Edit Vector Mask", |sh, _| { + if let Some(d) = density { + sh.density = d; + } + if let Some(f) = feather { + sh.feather = f; + } + Ok(()) + })?; + return vector_mask_info(s, id); + } s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1437,8 +1516,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(b) = p.get("linked").and_then(Value::as_bool) { vm.linked = b; } - if let Some(d) = f64p(p, "density") { - vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; + if let Some(d) = density { + vm.density = d; } if let Some(f) = feather { vm.feather = f; @@ -1683,7 +1762,7 @@ pub fn specs() -> Vec { "New Shape Layer", ["Layer", "New"], leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## )), has_doc, shape_create @@ -1693,7 +1772,7 @@ pub fn specs() -> Vec { "Edit Shape", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## )), has_layer, shape_edit @@ -1832,7 +1911,7 @@ pub fn specs() -> Vec { "Edit Vector Mask", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. On a shape layer (whose path is its vector mask) only density and feather apply: they soften its fill, which shows beyond the path at 100 − density %. {PATH_FORM}"## )), has_layer, vector_mask_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..a204c8f8fc 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,6 +335,63 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } +#[test] +fn path_make_selection_rejects_invalid_options_without_changing_history() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60], [12, 60]]}]}})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 12, "height": 12})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for opts in [ + json!({"feather": -1}), + json!({"feather": 1001}), + json!({"feather": 1e100}), + json!({"feather": "4"}), + json!({"feather": true}), + json!({"antiAlias": "false"}), + json!({"antiAlias": 1}), + json!({"mode": "union"}), + json!({"mode": 42}), + ] { + assert!(s.execute("path.toSelection", opts.clone()).is_err(), "accepted invalid Make Selection options: {opts}"); + assert_eq!(*doc(&s), before, "invalid Make Selection modified the document: {opts}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "invalid Make Selection changed history: {opts}"); + } +} + +#[test] +fn open_pen_paths_make_the_same_selection_as_closed_paths() { + // The pen's unfinished work path should be implicitly closed when rasterized into a selection. + let mut s = session(80, 80, 8); + let knots = json!([[12, 12], [52, 12], [52, 52], [12, 52]]); + for (aa, feather) in [(false, 0.0), (true, 0.0), (false, 3.0), (true, 3.0)] { + let mut masks = Vec::new(); + for closed in [false, true] { + s.execute("path.set", json!({"path": {"subpaths": [{"closed": closed, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"antiAlias": aa, "feather": feather, "mode": "replace"})).unwrap(); + masks.push(selection_mask(&s)); + } + assert_eq!(masks[0], masks[1], "open and closed paths differ (antiAlias={aa}, feather={feather})"); + assert!(masks[0][32 * 80 + 32] > 0.99); + assert_eq!(masks[0][4 * 80 + 4], 0.0); + if feather > 0.0 { + assert!(masks[0].iter().any(|v| *v > 0.0 && *v < 1.0), "feather did not soften the selection"); + } + } + + // All boolean operations should use the same selection semantics for an open Pen path. + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.rect", json!({"x": 30, "y": 30, "width": 34, "height": 25})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"mode": mode, "antiAlias": false})).unwrap(); + let mask = selection_mask(&s); + let at = |x: usize, y: usize| mask[y * 80 + x]; + assert_eq!(at(20, 20), if matches!(mode, "replace" | "add") { 1.0 } else { 0.0 }, "{mode}: path-only"); + assert_eq!(at(60, 40), if matches!(mode, "add" | "subtract") { 1.0 } else { 0.0 }, "{mode}: previous-only"); + assert_eq!(at(40, 40), if mode == "subtract" { 0.0 } else { 1.0 }, "{mode}: overlap"); + } +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); @@ -746,6 +803,71 @@ fn vector_mask_edit_rejects_out_of_range_feather() { assert_eq!(feather(&s), 1000.0); } +/// #5846: a shape layer's path is its vector mask; `layer.vectorMask.edit` sets its Density and +/// Feather in one undoable step, re-rendering the fill, which shows beyond the path at 100 − +/// density %. Hostile values are refused before any edit. +#[test] +fn shape_density_and_feather_through_vector_mask_edit() { + for depth in [8, 16, 32] { + let mut s = session(200, 200, depth); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [50, 50, 100, 100], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let info = s.execute("layer.vectorMask.info", json!({})).unwrap(); + assert_eq!((info["shape"].clone(), info["density"].clone(), info["feather"].clone()), (json!(true), json!(100.0), json!(0.0)), "{depth}"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("layer.vectorMask.edit", json!({"density": 50})).unwrap(); + assert_eq!(r["density"], 50.0); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (0.5, 0.0)); + // The shape's own pixels carry the soft fill (half-transparent black beyond the path). + let cache = sh.cache.as_ref().unwrap(); + assert!((cache.rgba(10, 10)[3] - 0.5).abs() < 0.01, "{depth}: {:?}", cache.rgba(10, 10)); + assert!(cache.rgba(100, 100)[3] > 0.99); + s.execute("layer.vectorMask.edit", json!({"feather": 10, "density": 100})).unwrap(); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (1.0, 10.0)); + let a = |x| sh.cache.as_ref().unwrap().rgba(x, 100)[3]; + assert!(a(30) > 0.0 && a(30) < 0.05 && (a(49) + a(50) - 1.0).abs() < 0.02 && a(70) > 0.95, "{depth}: {} {} {} {}", a(30), a(49), a(50), a(70)); + // Fill and stroke stay editable: the shape is still a shape. + s.execute("shape.edit", json!({"fill": "#ff0000"})).unwrap(); + assert_eq!(shape(&s, id).feather, 10.0, "shape.edit keeps the feather"); + s.undo(); + s.undo(); + assert_eq!((shape(&s, id).density, shape(&s, id).feather), (0.5, 0.0), "{depth}: undo restores"); + } +} + +#[test] +fn shape_vector_mask_edit_fails_gracefully() { + let mut s = session(64, 64, 8); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [8, 8, 30, 30]})).unwrap()["layer"].as_u64().unwrap(); + let before = doc(&s).clone(); + let steps = s.active().unwrap().history.past_len(); + let bad = [ + json!({"density": -1}), + json!({"density": 100.5}), + json!({"density": 1e39}), + json!({"density": "50"}), + json!({"density": null}), + json!({"feather": -0.5}), + json!({"feather": 1001}), + json!({"feather": [1]}), + json!({"feather": 5, "path": {"subpaths": []}}), + json!({"density": 50, "enabled": false}), + json!({"invert": true}), + json!({"layer": u64::MAX, "density": 50}), + ]; + for p in bad { + assert!(s.execute("layer.vectorMask.edit", p.clone()).is_err(), "{p}"); + assert_eq!(*doc(&s), before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } + // A locked shape refuses it too. + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + assert!(s.execute("layer.vectorMask.edit", json!({"layer": id, "density": 50})).is_err()); + assert_eq!(shape(&s, id).density, 1.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); @@ -819,6 +941,74 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not +/// what PhotoCraft would draw, a block of another colour, so a re-render shows). +fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId { + s.edit("shape", |doc, active| { + let fmt = doc.pixel_format(); + let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height())); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0])); + let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() }; + let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + }) + .unwrap() +} + +fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape layer: {}", other.kind_name()), + } +} + +fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool { + let c = sh.cache.as_ref().unwrap(); + let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y)); + (px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01 +} + +#[test] +fn moving_and_exporting_imported_shapes_keeps_their_rendering() { + for depth in [8, 16, 32] { + let mut s = session(100, 80, depth); + let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40)); + // The Move tool: the same pixels, moved; the path moves with them. + s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}"); + assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered"); + assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too"); + // Artboards to Files moves the board to the origin the same way. + s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap(); + let board = doc(&s).artboards()[0].0; + s.edit("into board", |doc, _| { + let l = doc.remove(id).unwrap(); + let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") }; + g.children.push(l); + Ok(()) + }) + .unwrap(); + let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap(); + let sh = shape_layer(&one, id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}"); + assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered"); + } +} + +#[test] +fn a_shape_reaching_past_the_canvas_renders_again_when_moved() { + let mut s = session(100, 80, 8); + // Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in. + let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40)); + s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)"); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows"); +} + #[test] fn shape_create_negative_size_rect_covers_the_ellipse_box() { // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and @@ -948,3 +1138,35 @@ fn combine_shapes_fails_gracefully() { assert!(s.execute("layer.combineShapes.unite", json!({})).is_err()); assert_eq!(*doc(&s), before); } + +#[test] +fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() { + let mut s = session(200, 200, 8); + let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.live.clone(), + _ => None, + }; + let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(), + _ => 0, + }; + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 6, "every vertex rounded"); + // Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again. + s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 12); + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap(); + assert_eq!(path_len(&s), 6); + // Hostile values never panic: negative and non-finite radii are clamped or ignored. + for bad in [json!(-5), json!(1e300), json!("x")] { + let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad})); + } + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0)); + // Scaling the layer by half halves the radius, as for a rectangle's radii. + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap(); + s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 6fb9ab4574..7713e0d36f 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -216,7 +216,7 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { /// Frames loaded from a file or an image sequence. struct Footage { frames: Vec, - /// The frame rate the footage itself implies (a single animated GIF's delays); `None` for + /// The frame rate the footage itself implies (a single animated GIF's or WebP's delays); `None` for /// stills and sequences, which carry none. fps: Option, } @@ -227,21 +227,36 @@ struct Footage { /// do, since they would otherwise play at an unintended 50-100 fps. fn fps_from_delays(delays: &[u32]) -> Option { let clean = |d: u32| if d < 20 { 100 } else { d }; - let mut counts: Vec<(u32, usize)> = Vec::new(); + // Count delays with a map keyed on the cleaned delay, recording first-seen order + // separately. A linear scan per frame (iter_mut().find) was O(n^2) for a GIF with many + // distinct delays. order[] preserves the tie-break: the earliest-seen delay wins. + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut order: Vec = Vec::new(); for d in delays.iter().copied().map(clean) { - match counts.iter_mut().find(|(k, _)| *k == d) { - Some((_, n)) => *n += 1, - None => counts.push((d, 1)), - } + let n = counts.entry(d).or_insert_with(|| { + order.push(d); + 0 + }); + *n += 1; } - let best = counts.iter().fold(None::<(u32, usize)>, |best, &(d, n)| match best { - Some((_, bn)) if bn >= n => best, - _ => Some((d, n)), + let best = order.iter().fold(None::<(u32, usize)>, |best, &d| { + let n = counts[&d]; + match best { + Some((_, bn)) if bn >= n => best, + _ => Some((d, n)), + } }); best.map(|(d, _)| 1000.0 / d as f32) } -/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF), or a +/// `photocraft_io::import_gif_frames` / `import_webp_frames`. +type ImportFrames = fn( + &str, + &[u8], + &mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>, +) -> std::result::Result; + +/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result { @@ -271,8 +286,14 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Some(photocraft_io::import_gif_frames as ImportFrames), + Some(photocraft_codecs::Format::WebP) if photocraft_codecs::webp_is_animated(&bytes) => Some(photocraft_io::import_webp_frames as ImportFrames), + _ => None, + }; + if let Some(import) = animation { + import(&name, &bytes, &mut |doc, delay_ms| { frames.push(crate::file_cmds::flattened(&doc, fmt)); delays.push(delay_ms); Ok(()) @@ -287,7 +308,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; // An explicit `fps` wins, then the rate the footage implies, then 30. - let fps = p.get("fps").and_then(Value::as_f64).map(|f| f as f32).or(footage_fps).unwrap_or(30.0); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); let id = s.edit("New Video Layer from File", |doc, active| { store(doc); @@ -341,10 +362,10 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { } fn interpret_footage(s: &mut Session, p: &Value) -> Result { - let fps = p.get("fps").and_then(Value::as_f64); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.interpretFootage", p)?; edit_video(s, "Interpret Footage", move |v, _| { if let Some(f) = fps { - v.fps = f as f32; + v.fps = f; } Ok(()) }) @@ -365,7 +386,9 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { let photocraft_doc::VideoSource::File { path } = src else { return Err(EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "this layer has no file source to reload".into() }); }; - let frames = load_frames(&path, fmt)?.frames; + // The footage path is stored in the document. + let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; + let frames = load_frames(&file.to_string_lossy(), fmt)?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -424,7 +447,9 @@ pub fn render_video_with( if frames > crate::timeline_cmds::MAX_DURATION { return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); } - let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); + // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are + // valid timelines, so only an unusable loaded rate falls back to 30. + let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); let bounds = base.bounds(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index c16c7f8e39..a3f304ce8e 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -151,6 +151,39 @@ fn a_fully_transparent_animated_gif_frame_encodes() { assert!(f.indices.iter().all(|&i| Some(i) == f.transparent)); } +/// The GIF frame delays (centiseconds, from each Graphic Control Extension) of a Render Video. +fn rendered_gif_delays(s: &Session) -> Vec { + let mut gif = Vec::new(); + render_video_with( + s, + &json!({"format": "gif"}), + |_, bytes| { + gif = bytes.to_vec(); + Ok(()) + }, + |_, _| true, + ) + .unwrap(); + gif.windows(6).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| u16::from_le_bytes([w[4], w[5]])).collect() +} + +#[test] +fn animated_gif_delay_follows_timelines_under_one_fps() { + // 0.5 fps is a valid timeline rate; its frames last 2 s, not the 1 s a 1 fps floor gave. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 2, "fps": 0.5})).unwrap(); + assert_eq!(rendered_gif_delays(&s), [200, 200]); + // A loaded timeline with an unusable rate falls back to 30 fps (3 cs), as with no timeline. + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(2, f32::NAN)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + assert_eq!(rendered_gif_delays(&s), [3, 3]); +} + #[test] fn inspect_reports_video_layer() { let dir = tmpdir("vinsp"); @@ -330,6 +363,32 @@ fn a_still_gif_is_one_frame_at_the_default_rate() { assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 10.0); } +/// Interpret Footage and New Video Layer from File reject an fps that isn't a finite number in +/// `(0, MAX_FPS]` and keep the layer as it was (#5657). 1e39 used to become an infinite rate the +/// next `.pcraft` save refused. +#[test] +fn an_invalid_frame_rate_is_an_error_and_changes_nothing() { + let mut s = session(); + let id = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let fps = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps; + assert_eq!(fps(&s), 30.0); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.interpretFootage", json!({"fps": bad})).is_err(), "fps {bad}"); + assert_eq!(fps(&s), 30.0, "fps {bad}"); + } + s.execute("layer.videoLayers.interpretFootage", json!({"fps": 24})).unwrap(); + assert_eq!(fps(&s), 24.0); + + let dir = tmpdir("gifin-badfps"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 10), ([0, 255, 0], 10)]); + let layers = s.active().unwrap().doc.layer_count(); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path, "fps": bad})).is_err(), "fps {bad}"); + assert_eq!(s.active().unwrap().doc.layer_count(), layers, "fps {bad}"); + } +} + #[test] fn video_frames_to_layers_takes_every_gif_frame() { let dir = tmpdir("gifin-ftl"); @@ -340,6 +399,80 @@ fn video_frames_to_layers_takes_every_gif_frame() { assert_eq!(r["layers"], 4); } +// ------------------------------------------------------------------ animated WebP footage (#1198) + +/// An animated WebP of solid `w`x`h` frames: one `(colour, duration in ms)` each, every frame a +/// lossless still in an ANMF chunk. +fn write_webp(path: &str, w: u32, h: u32, frames: &[([u8; 3], u32)]) { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, encode}; + let chunk = |id: &[u8; 4], data: &[u8]| { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c + }; + let u24 = |v: u32| v.to_le_bytes()[..3].to_vec(); + let mut body = b"WEBP".to_vec(); + body.extend(chunk(b"VP8X", &[[0x02, 0, 0, 0].as_slice(), &u24(w - 1), &u24(h - 1)].concat())); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for &(c, ms) in frames { + let img = Image::from_u8(w, h, ChannelLayout::Rgb, c.repeat((w * h) as usize)).unwrap(); + let still = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap(); + let at = still.windows(4).position(|w| w == b"VP8L").unwrap(); + let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize; + let anmf = [[0; 6].as_slice(), &u24(w - 1), &u24(h - 1), &u24(ms), &[0x02], &chunk(b"VP8L", &still[at + 8..at + 8 + len])].concat(); + body.extend(chunk(b"ANMF", &anmf)); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend(body); + std::fs::write(path, out).unwrap(); +} + +#[test] +fn an_animated_webp_becomes_a_video_layer_with_every_frame() { + let dir = tmpdir("webpin"); + let path = format!("{dir}/anim.webp"); + write_webp(&path, 16, 12, &[([255, 0, 0], 50), ([0, 255, 0], 50), ([0, 0, 255], 80)]); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 3); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(frame_rgb(&s, id, 0), [1.0, 0.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 1), [0.0, 1.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 2), [0.0, 0.0, 1.0]); + // The most common duration, 50 ms: 20 fps. + assert_eq!(s.active().unwrap().doc.timeline.as_ref().unwrap().fps, 20.0); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(id).is_none()); +} + +#[test] +fn video_frames_to_layers_takes_every_webp_frame() { + let dir = tmpdir("webpin-ftl"); + let path = format!("{dir}/anim.webp"); + write_webp(&path, 8, 8, &[([255, 0, 0], 40), ([0, 255, 0], 40), ([0, 0, 255], 40)]); + let mut s = session(); + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 3); +} + +#[test] +fn a_still_webp_is_one_frame_at_the_default_30_fps() { + let dir = tmpdir("webpin-still"); + let path = format!("{dir}/still.webp"); + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap(); + std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &Default::default()).unwrap()).unwrap(); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 1); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 30.0); +} + #[test] fn a_gif_rendered_by_render_video_loads_back_frame_for_frame() { let src = tmpdir("gifrt-src"); @@ -520,3 +653,19 @@ fn render_video_rejects_gif_frames_too_large_to_stage() { let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err(); assert!(err.to_string().contains("too large"), "{err}"); } + +#[test] +fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() { + // Most common delay wins: 100 ms appears twice, 40 ms once -> 10 fps. + assert_eq!(fps_from_delays(&[100, 40, 100]), Some(10.0)); + // Tie between 40 ms (25 fps) and 50 ms (20 fps): the first seen (40) wins. + assert_eq!(fps_from_delays(&[40, 50]), Some(25.0)); + // Delays under 20 ms are treated as 100 ms. + assert_eq!(fps_from_delays(&[0, 10, 5]), Some(10.0)); + assert_eq!(fps_from_delays(&[]), None); + // Many distinct delays used to be quadratic; this must stay fast and correct. Delay 20 + // (the one appearing twice) wins at 50 fps. + let mut many: Vec = (20..5_020).collect(); + many.push(20); + assert_eq!(fps_from_delays(&many), Some(50.0)); +} diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..31de399b18 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -9,10 +9,10 @@ //! what the Free Transform warp UI uses) or the active smart object's stored warp (a history step). use photocraft_algo::transform::{Homography, Interp}; -use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; +use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface_with}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, Rect}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -112,7 +112,24 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). -fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { +/// `Err(Cancelled)` once `ctl` is cancelled: between the steps of a warp that don't check it. +fn cancelled(ctl: &Interrupt) -> Result<()> { + if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) } +} + +/// `ctl` reports progress and cancels (`Err(Cancelled)`, the layer left half-done for the caller +/// to discard). +#[allow(clippy::too_many_arguments)] +fn warp_layer( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + w: &Warp, + rect: Rect, + interp: Interp, + frames: Option<&Frames>, + ctl: &Interrupt, +) -> Result<()> { let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); @@ -121,10 +138,13 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model. + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - warp_layer(None, locks, c, w, rect, interp, frames)?; + warp_layer(None, locks, c, w, rect, interp, frames, ctl)?; } } LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())), @@ -151,7 +171,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, sm.warp = src_warp.filter(|w| !w.is_identity()); // Fallback appearance for sources that can't be re-rendered. if let Some(c) = &mut sm.cache { - *c = warp_mesh_surface(c, rect, &map, interp); + *c = warp_mesh_surface_with(c, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; } if let Some(m) = &mut sm.filter_mask { m.surface = warp_mesh_gray(&m.surface, &map, interp); @@ -162,7 +182,8 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, *surf = match doc_sel { Some(sel) => { let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?; - let moved = warp_mesh_surface(&lifted, rect, &map, interp); + let moved = warp_mesh_surface_with(&lifted, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; + cancelled(ctl)?; crate::transform_cmds::composite_over(&mut rest, &moved); rest.prune(); rest @@ -170,12 +191,20 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, None => { // Content outside the warp box stays where it is. let fmt = surf.format(); - let warped = warp_mesh_surface(surf, rect, &map, interp); - let mut rest = surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); + let warped = warp_mesh_surface_with(surf, rect, &map, interp, ctl).ok_or(EngineError::Cancelled)?; + cancelled(ctl)?; + // Tiles are shared, so a surface that has alpha already costs nothing to copy. + let mut rest = if fmt.alpha { surf.clone() } else { surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)) }; crate::pixels::try_clear_surface(&mut rest, rect, None)?; - crate::transform_cmds::composite_over(&mut rest, &warped); - rest.prune(); - rest + if rest.content_bounds().is_empty() { + // The box held all of the layer: the warp is the whole result. + warped + } else { + cancelled(ctl)?; + crate::transform_cmds::composite_over(&mut rest, &warped); + rest.prune(); + rest + } } }; } @@ -186,7 +215,12 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, { let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp)); m.surface = match mask_sel { - Some(sel) => crate::transform_cmds::move_gray_selected(&m.surface, sel, &|lifted, src| Ok(warp_mesh_surface(lifted, src, &map, interp)), &whole)?, + Some(sel) => crate::transform_cmds::move_gray_selected( + &m.surface, + sel, + &|lifted, src| warp_mesh_surface_with(lifted, src, &map, interp, ctl).ok_or(EngineError::Cancelled), + &whole, + )?, None => whole(&m.surface)?, }; } @@ -321,7 +355,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } s.edit("Warp", |doc, _| { let group = crate::transform_cmds::group_locks(doc, id); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let locks = l.locks.union(group); if locks.position || locks.all { @@ -329,7 +363,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) }; t.warp = tw.clone(); - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); Ok(()) })?; Ok(json!({"layer": id.0, "changed": true, "warp": tw})) @@ -347,30 +381,39 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); - s.edit("Warp", |doc, _| { - let sel = doc.selection.clone(); - let frames = content_knots.then(|| { - let mut f = Frames::new(); - if let Some(l) = doc.layer(id) { - source_frames(doc, l, &mut f); - } - f - }); - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = crate::transform_cmds::group_locks(doc, id); - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp, frames.as_ref())?; - let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - crate::transform_cmds::refresh_text(&snapshot, l); + // A background job when started with `Session::start` (as Free Transform, #4218): the warp of + // a large layer runs on a worker with progress, and can be cancelled. + crate::jobs::edit_job( + s, + "Warp", + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Warping", |ctl| warp_doc(doc, id, &w, rect, interp, content_knots, ctl)), + move |()| json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]}), + ) +} + +/// The edit of [`apply`]: the layer and the selection outline. +fn warp_doc(doc: &mut Document, id: LayerId, w: &Warp, rect: Rect, interp: Interp, content_knots: bool, ctl: &Interrupt) -> Result<()> { + let sel = doc.selection.clone(); + let frames = content_knots.then(|| { + let mut f = Frames::new(); + if let Some(l) = doc.layer(id) { + source_frames(doc, l, &mut f); } - if let Some(sel) = &doc.selection { - doc.selection = Some(warp_mesh_gray(sel, &|x, y| w.map(x, y), Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); - } - Ok(()) - })?; - Ok(json!({"layer": id.0, "changed": true, "rect": [rect.x0, rect.y0, rect.x1, rect.y1]})) + f + }); + let is_group = doc.layer(id).is_some_and(Layer::is_group); + let group = crate::transform_cmds::group_locks(doc, id); + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + warp_layer(if is_group { None } else { sel.as_ref() }, group, l, w, rect, interp, frames.as_ref(), ctl)?; + let snapshot = doc.clone(); + if let Some(l) = doc.layer_mut(id) { + crate::transform_cmds::refresh_text(&snapshot, l); + } + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_mesh_gray(sel, &|x, y| w.map(x, y), Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + } + Ok(()) } #[derive(Clone, Copy)] @@ -627,6 +670,36 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn a_warp_box_holding_the_whole_layer_gives_what_compositing_over_the_cleared_layer_did() { + for depth in [8, 16, 32] { + let s = session(depth); + let st = s.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let surf = l.surface().unwrap().clone(); + let rect = surf.content_bounds(); + let w = Warp::preset(WarpStyle::Flag, 35.0, rect_f(rect)); + // What the warp built before the shortcut: the warp composited over the cleared layer. + let map = |x: f64, y: f64| w.map(x, y); + let warped = warp_mesh_surface_with(&surf, rect, &map, Interp::Bicubic, &Interrupt::NONE).unwrap(); + let mut want = surf.clone(); + crate::pixels::try_clear_surface(&mut want, rect, None).unwrap(); + crate::transform_cmds::composite_over(&mut want, &warped); + want.prune(); + let mut got = l.clone(); + warp_layer(None, Locks::default(), &mut got, &w, rect, Interp::Bicubic, None, &Interrupt::NONE).unwrap(); + let got = got.surface().unwrap(); + let b = want.content_bounds(); + assert_eq!(got.content_bounds(), b, "depth {depth}"); + let (g, wv) = (got.read_region(b), want.read_region(b)); + // Exact at 8 and 16 bits. At 32 the composite's `t·a / a` rounds a few values by an + // ulp; the shortcut keeps the warp's own. + let tol = if depth == 32 { 1e-6 } else { 0.0 }; + let worst = g.iter().zip(&wv).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst <= tol, "depth {depth}: off by {worst}"); + } + } + fn session(depth: u64) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap(); @@ -891,4 +964,27 @@ mod tests { assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y}): {}", mask_at(&s, x, y)); } } + + /// Esc stops the warp of a selected linked mask too, not only of the pixels. A fill layer has + /// no pixels to warp, so the mask is the only part that can see the cancel. + #[test] + fn a_cancelled_warp_stops_in_a_selected_linked_mask() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("link", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().linked = true; + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let st = s.active().unwrap(); + let (sel, mut l) = (st.doc.selection.clone().unwrap(), st.doc.layer(st.active_layer.unwrap()).unwrap().clone()); + let rect = Rect::new(10, 10, 20, 20); + let w = Warp::preset(WarpStyle::Flag, 35.0, rect_f(rect)); + let ctl = Interrupt::cancel_only(&|| true); + let r = warp_layer(Some(&sel), Locks::default(), &mut l, &w, rect, Interp::Bicubic, None, &ctl); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + } } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..1ed070c020 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -19,7 +19,7 @@ use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::file_cmds::{join, native_doc, stem, write_file}; +use crate::file_cmds::{is_windows_device_name, join, native_doc, portable_device_name, stem, write_file}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -70,6 +70,9 @@ pub enum WebMetadata { None, Copyright, CopyrightAndContact, + /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). + AllExceptCameraInfo, + /// All the XMP and the photo's EXIF. All, } @@ -203,8 +206,9 @@ impl WebSettings { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, - "all" | "allExceptCameraInfo" => WebMetadata::All, - m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), + "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, + "all" => WebMetadata::All, + m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { @@ -262,6 +266,7 @@ impl WebSettings { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", + WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); @@ -387,7 +392,7 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, - WebMetadata::All => Some(xmp.to_string()), + WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); @@ -405,10 +410,33 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { } } +/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). +pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { + doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) +} + +/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). +fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { + exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) + .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) + .into_iter() + .collect() +} + /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] -pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { +pub fn optimize( + px: &[[f32; 4]], + bw: usize, + rect: Rect, + st: &WebSettings, + icc: Option<&[u8]>, + exif: Option<&[u8]>, + xmp: Option<&str>, + dpi: f32, + preview: bool, +) -> Result { if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) { return Err(other("slice is outside the pixel buffer")); @@ -460,8 +488,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) - .map_err(e)?; + let bytes = + photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -557,7 +586,7 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let bytes = if st.format == WebFormat::Gif { photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)? } else { - photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? + photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None, None).map_err(e)? }; if preview { indices = idx; @@ -703,7 +732,7 @@ fn slice_file(name: &str) -> String { /// Generated filenames should remain usable on Windows even when a document or slice is named /// after a reserved device. Caller-supplied path components are still rejected below. fn portable_generated_stem(stem: String) -> String { - if is_windows_device_asset_name(&stem) { format!("{stem}_") } else { stem } + portable_device_name(stem) } fn html_escape(s: &str) -> String { @@ -873,7 +902,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; + let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); @@ -895,18 +926,18 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); @@ -933,53 +964,42 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } name }; - // Stacking order: later stored slices are on top (auto slices never overlap anything). - let z = |r: &ResolvedSlice| r.id.and_then(|id| doc.slices.list.iter().position(|sl| sl.id == id)); + // Slices never overlap here: one covered by later slices comes as its visible pieces (see + // `slices::resolve`), each its own image and cell, as Photoshop's subslices. A no-image cell's + // text goes into its first piece only. + let mut texted = std::collections::HashSet::new(); for r in &chosen { - let rect = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); - if rect.is_empty() { + let piece = scale_rect(r.rect, sx, sy).intersect(&wdoc.bounds()); + if piece.is_empty() { continue; } - // In the HTML table a slice shows only where no slice above covers it; that visible part - // is cut into rectangles, each its own cell (and image), as Photoshop's subslices. - let pieces = if html { - let above: Vec = chosen.iter().filter(|o| z(o) > z(r)).map(|o| scale_rect(o.rect, sx, sy).intersect(&wdoc.bounds())).collect(); - slices::auto_slices(rect, &above) - } else { - vec![rect] - }; - let split = pieces.len() > 1; let stored = r.id.and_then(|id| doc.slices.get(id)); - for (k, piece) in pieces.into_iter().enumerate() { - if r.kind == SliceKind::NoImage { - let text = if k > 0 { - String::new() - } else { - stored.map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }).unwrap_or_default() - }; - let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); - cells.push((piece, bg, text)); - continue; - } - let o = optimize(&buf.px, bw, piece, &st, icc, xmp.as_deref(), dpi, false)?; - let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; - let name = unique(&stem, o.ext); - let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; - let out = join(&dir, &rel); - write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; - total += o.bytes.len(); - files.push(out); - let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); - let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); - let body = match stored.filter(|s| safe_slice_url(&s.url)) { - Some(s) => { - let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; - format!("\n\t\t\t{img}", html_escape(&s.url)) - } - None => img, - }; - cells.push((piece, String::new(), body)); + if r.kind == SliceKind::NoImage { + let text = stored + .filter(|s| texted.insert(s.id)) + .map(|s| if trusted_slice_html && s.cell_text_is_html { s.cell_text.clone() } else { html_escape(&s.cell_text) }) + .unwrap_or_default(); + let bg = stored.and_then(|s| s.background).map(|c| format!(" bgcolor=\"#{:02X}{:02X}{:02X}\"", c[1], c[2], c[3])).unwrap_or_default(); + cells.push((piece, bg, text)); + continue; } + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; + let name = unique(&slice_file(&r.name), o.ext); + let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; + let out = join(&dir, &rel); + write(&web_derived_path(&dir, &rel, native_paths)?, &o.bytes)?; + total += o.bytes.len(); + files.push(out); + let alt = stored.map_or(String::new(), |s| html_escape(&s.alt)); + let img = format!("\n\t\t\t\"{alt}\"", html_escape(&rel), piece.width(), piece.height()); + let body = match stored.filter(|s| safe_slice_url(&s.url)) { + Some(s) => { + let target = if s.target.is_empty() { String::new() } else { format!(" target=\"{}\"", html_escape(&s.target)) }; + format!("\n\t\t\t{img}", html_escape(&s.url)) + } + None => img, + }; + cells.push((piece, String::new(), body)); } let mut html_path = None; if html { @@ -997,7 +1017,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) + Ok( + json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), + ) } // ---------- Export Preferences / Quick Export ---------- @@ -1024,27 +1046,65 @@ fn export_preferences(s: &mut Session, p: &Value) -> Result { Ok(json!({"section": "export", "values": s.prefs().get("export").unwrap_or(Value::Null)})) } +/// The Quick Export format's extension (Export Preferences › Format). +fn quick_export_ext(s: &Session) -> String { + serde_json::to_value(s.prefs().export.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()) +} + +/// Where Quick Export writes the active document with no `path` given: `.` beside +/// its file, when Export Preferences › Location is Same Folder and the document has a file. +pub fn quick_export_target(s: &Session) -> Option { + let same = s.prefs().export.quick_export_location == crate::prefs::ExportLocation::SameFolder; + let dp = s.active()?.path.as_deref().filter(|_| same)?; + let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + Some(join(&dir, &format!("{}.{}", stem(dp), quick_export_ext(s)))) +} + +/// Whether [`quick_export_target`] is the active document's own file (a document opened from +/// `photo.png` and exported as PNG), which Quick Export must not replace without asking (#6599). +pub fn quick_export_target_is_own_file(s: &Session) -> bool { + let own = s.active().and_then(|d| d.path.as_deref()); + quick_export_target(s).zip(own).is_some_and(|(target, own)| crate::file_cmds::same_file(&target, own)) +} + /// File › Export › Quick Export as : the format, quality, metadata, colour space and /// location from Export Preferences. `path` overrides the location ("ask" needs one). fn quick_export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.quickExport"; let prefs = s.prefs().export.clone(); - let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()); - let d = s.active().ok_or(EngineError::NoDocument)?; - let doc = d.doc.clone(); + let fmt = quick_export_ext(s); let path = match p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()) { Some(v) => v.to_string(), None => { - let same = serde_json::to_value(prefs.quick_export_location).ok().and_then(|v| v.as_str().map(str::to_string)).is_some_and(|v| v == "sameFolder"); - match (&d.path, same) { - (Some(dp), true) => { - let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); - join(&dir, &format!("{}.{fmt}", stem(dp))) - } - _ => return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")), + let Some(target) = quick_export_target(s) else { + return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")); + }; + // The document's own file is never replaced by an export the user didn't name (#6599). + if quick_export_target_is_own_file(s) { + return Err(bad(cmd, format!("the export would replace the document's own file {target}; give a `path`"))); } + target } }; + // The name typed in the save dialog wins over Export Preferences' format: Quick Export as PNG + // renamed to `photo.jpeg` must write a JPEG, not PNG bytes upload checks refuse (#8519). + let fmt = match crate::file_cmds::extension(&path).as_deref() { + None => fmt, + Some("jpg" | "jpeg") => "jpg".into(), + Some(e @ ("png" | "gif" | "webp" | "exr")) => e.into(), + Some(e) => return Err(bad(cmd, format!("Quick Export writes PNG, JPEG, GIF, WebP or EXR, not .{e}; use Export As"))), + }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let doc = d.doc.clone(); + if fmt == "exr" { + // EXR keeps floating-point scene values. The ordinary exporter converts to linear + // light; the web/sRGB pipeline would instead clamp HDR and negative values. + let opts = photocraft_io::ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; + let out = photocraft_io::export(&doc, "exr", &opts).map_err(other)?; + write_file(&path, &out.bytes)?; + crate::automate_cmds::fire_event(s, "export"); + return Ok(json!({"path": path, "format": fmt, "bytes": out.bytes.len(), "warnings": out.warnings})); + } let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( @@ -1087,19 +1147,21 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) + Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- @@ -1168,8 +1230,12 @@ fn parse_size(item: &str, dpi: f64) -> (Option, Option, Option, & && toks[1].eq_ignore_ascii_case("x") && let (Some(w), Some(h)) = (unit_px(toks[0], dpi), unit_px(toks[2], dpi)) { - let pos = item.find(toks[3]).unwrap_or(item.len()); - return (None, w, h, &item[pos..]); + // Skip exactly the three size tokens; searching for the name's first word could match inside the size. + let mut rest = item; + for _ in 0..3 { + rest = rest.trim_start().trim_start_matches(|c: char| !c.is_whitespace()); + } + return (None, w, h, rest.trim_start()); } (None, None, None, item) } @@ -1247,13 +1313,6 @@ fn asset_settings(spec: &AssetSpec) -> WebSettings { } } -fn is_windows_device_asset_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); - 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")) -} - /// Layer names and `default` folders are document data, so reject unsafe output components. fn asset_path_components(relative: &str) -> Result> { // Split both separator styles even on Unix, where a backslash would otherwise be a legal @@ -1267,7 +1326,7 @@ fn asset_path_components(relative: &str) -> Result> { || part.ends_with(' ') || part.contains(':') || part.contains('\0') - || is_windows_device_asset_name(part) + || is_windows_device_name(part) { return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}"))); } @@ -1337,10 +1396,15 @@ fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { } /// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors). +/// +/// Each file is written once per run (#6614): when two layers (or two default variants) name the +/// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`. +/// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and +/// macOS. pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); let mut defaults: Option> = None; - let mut jobs: Vec<(LayerId, AssetSpec)> = Vec::new(); + let mut by_layer: Vec> = Vec::new(); for (_, _, l) in doc.walk() { if let Some(d) = parse_defaults(&l.name, dpi) { defaults = Some(d); @@ -1349,22 +1413,26 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if matches!(l.content, LayerContent::Adjustment(_)) { continue; } - for spec in parse_asset_name(&l.name, dpi) { - jobs.push((l.id, spec)); - } + by_layer.push(parse_asset_name(&l.name, dpi).into_iter().map(|spec| (l.id, spec)).collect()); } + // Top layer first, as the Layers panel lists them (`walk` goes bottom to top). + let jobs: Vec<(LayerId, AssetSpec)> = by_layer.into_iter().rev().flatten().collect(); let variants: Vec = match defaults { Some(d) if !d.is_empty() => d, _ => vec![AssetDefault::default()], }; let mut files = Vec::new(); let mut errors = Vec::new(); + let mut written = std::collections::HashSet::new(); for (id, spec) in &jobs { for v in &variants { let r = (|| -> Result { let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); - asset_path_components(&rel)?; + let key = asset_path_components(&rel)?.join("/").to_lowercase(); + if written.contains(&key) { + return Err(other(format!("{rel}: another layer already writes this file; rename one of the layers"))); + } let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); @@ -1392,12 +1460,14 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if spec.format == "webp" { let (out, reported) = asset_output_path(dir, &rel)?; crate::file_cmds::save_doc(&wdoc, &out, None)?; + written.insert(key); return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); - let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; + let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; + written.insert(key); Ok(reported) })(); match r { @@ -1468,7 +1538,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), @@ -1477,7 +1547,7 @@ pub fn specs() -> Vec { "Export Preferences…", &["File", "Export"], None, - r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, + r##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, |_| Ok(()), export_preferences ), @@ -1486,7 +1556,7 @@ pub fn specs() -> Vec { "Quick Export", &[], None, - r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs index 1d319cb382..0c25ce0d24 100644 --- a/crates/engine/src/web_cmds/color_table_tests.rs +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -7,13 +7,13 @@ fn color_replacement_preserves_pixel_membership_and_transparency() { for dither in ["none", "diffusion", "pattern", "noise"] { let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); let original = WebSettings::from_params(&p, "test").unwrap(); - let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, None, 72.0, true).unwrap(); let mut edited = p.clone(); // Replace red with blue: re-quantization would now send all opaque pixels to blue. edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); let settings = WebSettings::from_params(&edited, "test").unwrap(); assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); - let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, None, 72.0, true).unwrap(); for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { if old[3] == 0 { assert_eq!(new[3], 0); @@ -81,8 +81,8 @@ fn malformed_tables_and_out_of_bounds_slices_return_errors() { } let settings = WebSettings::default(); for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { - assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, None, 72.0, false).is_err()); } let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); - assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, None, 72.0, true).is_err()); } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..9ba644719d 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -167,6 +167,19 @@ fn reserved_document_and_slice_names_export_with_portable_names() { assert!(std::path::Path::new(result["html"].as_str().unwrap()).is_file()); } +#[test] +fn a_dotted_device_file_name_exports_without_the_device_stem() { + // `nul.v2_` would still be the NUL device on Windows; `nul_.v2` is a file. + let dir = tmp("reserved-dotted-name"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 16, 16], "name": "logo"})).unwrap(); + let path = format!("{dir}/nul.v2.png"); + let result = s.execute("file.export.saveForWebLegacy", json!({"path": path, "slices": "all", "format": "png24", "html": true})).unwrap(); + let html = result["html"].as_str().unwrap(); + assert!(html.ends_with("/nul_.v2.html"), "{html}"); + assert!(std::path::Path::new(html).is_file()); +} + #[test] fn platform_writer_failure_stops_export_and_preserves_saved_settings() { let mut s = session(8); @@ -437,6 +450,78 @@ fn export_preferences_drive_quick_export() { s.execute("prefs.reset", json!({"path": "export"})).unwrap(); } +/// #6599: with Location = Same Folder, a document opened from `photo.png` would be exported over +/// `photo.png` itself. Without a `path` that is refused and the file is left alone; named +/// explicitly (the save dialog's answer) it is written. +#[test] +fn quick_export_never_replaces_the_documents_own_file_unasked() { + let dir = tmp("quick_own_file"); + let mut s = session(8); + s.execute("file.export.exportPreferences", json!({"quickExportLocation": "sameFolder", "quickExportFormat": "png"})).unwrap(); + let own = format!("{dir}/photo.png"); + std::fs::write(&own, b"original").unwrap(); + s.active_mut().unwrap().path = Some(own.clone()); + assert_eq!(quick_export_target(&s), Some(own.clone())); + assert!(quick_export_target_is_own_file(&s)); + let e = s.execute("file.export.quickExport", json!({})).unwrap_err().to_string(); + assert!(e.contains("own file"), "{e}"); + assert_eq!(std::fs::read(&own).unwrap(), b"original", "the source is untouched"); + s.execute("file.export.quickExport", json!({"path": own})).unwrap(); + assert_eq!(decode(&own).dimensions(), (64, 48)); + // Another format, or a PSD's earlier export beside it, is still written without asking. + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg"})).unwrap(); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(format!("{dir}/photo.jpg"))); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "png"})).unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/photo.psd")); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(own)); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// #8519: Quick Export as PNG renamed to `photo.jpeg` in the save dialog wrote PNG bytes under a +/// JPEG name, which upload checks refuse. A Quick Export extension picks the format; another +/// image extension is refused; no extension keeps Export Preferences' format. +#[test] +fn quick_export_writes_the_format_its_path_names() { + let dir = tmp("quick_ext"); + let mut s = session(8); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "png"})).unwrap(); + for (name, format, magic) in [ + ("a.jpeg", "jpg", &b"\xFF\xD8\xFF"[..]), + ("b.JPG", "jpg", b"\xFF\xD8\xFF"), + ("c.gif", "gif", b"GIF8"), + ("d.webp", "webp", b"RIFF"), + ("e", "png", b"\x89PNG"), + ] { + let path = format!("{dir}/{name}"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + assert_eq!(r["format"], json!(format), "{name}"); + assert!(std::fs::read(&path).unwrap().starts_with(magic), "{name} holds {format} data"); + } + let tif = format!("{dir}/f.tif"); + let e = s.execute("file.export.quickExport", json!({"path": tif})).unwrap_err().to_string(); + assert!(e.contains("Export As"), "{e}"); + assert!(!std::path::Path::new(&tif).exists()); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +#[test] +fn same_file_sees_through_other_names_for_one_file() { + let dir = tmp("same_file"); + let (a, b, c) = (format!("{dir}/a.png"), format!("{dir}/b.png"), format!("{dir}/c.png")); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&c, b"c").unwrap(); + assert!(crate::file_cmds::same_file(&a, &a)); + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); + assert!(!crate::file_cmds::same_file(&a, &c)); + assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); + #[cfg(windows)] + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); +} + #[test] fn webp_export_preferences_select_lossless_or_lossy_output() { let dir = tmp("quick_webp_quality"); @@ -485,6 +570,20 @@ fn webp_export_preferences_select_lossless_or_lossy_output() { } } +#[test] +fn generator_spaced_size_keeps_only_the_file_name() { + for (name, w, h, file) in [ + ("2 x 2 2 icon.png", 2.0, 2.0, "2 icon.png"), + ("1in x 1in in.png", 72.0, 72.0, "in.png"), + ("2px x 1px x.png", 2.0, 1.0, "x.png"), + ("2in x 1in label name.png", 144.0, 72.0, "label name.png"), + ] { + let a = parse_asset_name(name, 72.0); + assert_eq!(a.len(), 1, "{name}"); + assert_eq!((a[0].width, a[0].height, a[0].file.as_str()), (Some(w), Some(h), file), "{name}"); + } +} + #[test] fn generator_naming_grammar() { let a = parse_asset_name("200% foo@2x.png, 48x48 icons/bar.png8 + photo.jpg8", 72.0); @@ -546,6 +645,47 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// #6614: two layers, or two default variants, naming one file never overwrite each other: the +/// layer higher in the Layers panel keeps the file, whatever the case of its name, and the +/// others are reported. +#[test] +fn image_assets_never_write_one_file_twice() { + let dir = tmp("asset-collisions"); + let mut s = session(8); + s.edit("paint", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("Top", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + let mut d = (*s.active().unwrap().doc).clone(); + let id = |d: &photocraft_doc::Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + let (top, logo) = (id(&d, "Top"), id(&d, "Logo")); + d.layer_mut(top).unwrap().name = "Logo.PNG".into(); + d.layer_mut(logo).unwrap().name = "logo.png, logo.png".into(); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!(files.iter().map(|p| std::path::PathBuf::from(p.as_str())).collect::>(), vec![std::path::Path::new(&dir).join("Logo.png")]); + assert_eq!(decode(&files[0]).dimensions(), (4, 4), "the top layer keeps the file"); + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("another layer already writes this file")), "{errors:?}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "one file, not a second one under another case"); + + // Two default variants that land on one name: the first keeps it. + 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 }))); + 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"]); + assert_eq!(decode(&files[0]).dimensions(), (2, 2), "the 50% variant keeps icon.png"); + assert_eq!(errors.len(), 1, "{errors:?}"); +} + #[test] fn image_assets_reject_traversal_from_names_and_defaults() { let base = tmp("asset-path-guard"); @@ -635,7 +775,7 @@ fn previews_show_the_optimised_pixels() { let buf = photocraft_compose::flatten(&wd); for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); - let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); // Red square centre stays red (WBMP: dark). let i = (16 * 64 + 16) * 4; @@ -645,7 +785,7 @@ fn previews_show_the_optimised_pixels() { } else { assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}"); } - let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap(); + let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, false).unwrap(); assert!(none.preview.is_empty()); assert_eq!(none.bytes, o.bytes); } @@ -656,7 +796,7 @@ fn jpeg_preview_at_odd_sizes() { for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] { let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect(); let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap(); - let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, None, 72.0, true).unwrap(); for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] { let i = (y * w + x) * 4; let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32]; @@ -685,6 +825,21 @@ fn slices_with_the_same_name_get_distinct_files() { assert!(html.contains("images/tile.gif") && html.contains("images/tile_2.gif")); } +#[test] +fn overlapping_slices_export_each_pixel_once() { + // #7770: without HTML too, a slice covered by a later one is written as its visible pieces. + let dir = tmp("overlap_images"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 40, 30], "name": "under"})).unwrap(); + s.execute("slice.new", json!({"rect": [20, 15, 40, 30], "name": "over"})).unwrap(); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "dir": dir, "slices": "user"})).unwrap(); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let dims: Vec<(u32, u32)> = files.iter().map(|f| decode(f).dimensions()).collect(); + files.iter_mut().for_each(|f| *f = f.rsplit('/').next().unwrap().to_string()); + assert_eq!(files, ["under.png", "under-03.png", "over.png"]); + assert_eq!(dims, [(40, 15), (20, 15), (40, 30)]); +} + #[test] fn overlapping_slices_keep_the_later_slice_in_html() { // #909: the later slice is on top; the earlier one shows only where it isn't covered. @@ -709,8 +864,209 @@ fn overlapping_slices_keep_the_later_slice_in_html() { }) .sum(); assert_eq!(area, 64 * 48); - assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}"); + // As Photoshop names subslices: the first piece keeps the name, the others add their number. + let under = files.iter().find(|f| f.ends_with("images/under.png")).unwrap(); + assert_eq!(decode(under).dimensions(), (40, 15), "the covered slice is cut into pieces: {files:?}"); + assert!(files.iter().any(|f| f.ends_with("images/under-03.png")), "{files:?}"); } #[path = "color_table_tests.rs"] mod color_table_tests; + +/// A little-endian camera EXIF: IFD0 with Make, Orientation 6, 300 ppi and pointers to an Exif +/// IFD (DateTimeOriginal) and a GPS IFD (GPSLatitudeRef); IFD0 links to an IFD1 thumbnail. +fn camera_exif() -> Vec { + let entry = |v: &mut Vec, tag: u16, ty: u16, count: u32, value: u32| { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&ty.to_le_bytes()); + v.extend_from_slice(&count.to_le_bytes()); + v.extend_from_slice(&value.to_le_bytes()); + }; + let (ifd0, data0) = (8u32, 8 + 2 + 7 * 12 + 4); + let (make, xres, yres) = (data0, data0 + 10, data0 + 18); + let exif_ifd = data0 + 26; + let date = exif_ifd + 2 + 12 + 4; + let gps_ifd = date + 20; + let ifd1 = gps_ifd + 2 + 12 + 4; + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&ifd0.to_le_bytes()); + v.extend_from_slice(&7u16.to_le_bytes()); + entry(&mut v, 0x010F, 2, 9, make); + entry(&mut v, 0x0112, 3, 1, 6); + entry(&mut v, 282, 5, 1, xres); + entry(&mut v, 283, 5, 1, yres); + entry(&mut v, 296, 3, 1, 2); + entry(&mut v, 0x8769, 4, 1, exif_ifd); + entry(&mut v, 0x8825, 4, 1, gps_ifd); + v.extend_from_slice(&ifd1.to_le_bytes()); + v.extend_from_slice(b"TestMake\0\0"); + for _ in 0..2 { + v.extend_from_slice(&300u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + } + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x9003, 2, 20, date); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"2024:05:01 10:30:00\0"); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0001, 2, 2, u32::from_le_bytes(*b"N\0\0\0")); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0103, 3, 1, 6); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), ifd1 as usize + 18); + v +} + +/// The payload (after `Exif\0\0`) of the file's EXIF APP1 segment, read from the markers. +fn exif_app1(bytes: &[u8]) -> Option> { + let mut i = 2; + while i + 4 <= bytes.len() && bytes[i] == 0xFF && bytes[i + 1] != 0xDA { + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let body = &bytes[i + 4..i + 2 + len]; + if bytes[i + 1] == 0xE1 && body.starts_with(b"Exif\0\0") { + return Some(body[6..].to_vec()); + } + i += 2 + len; + } + None +} + +/// IFD0's entries (tag → inline value) and its link to the next IFD, little-endian only. +fn ifd0(tiff: &[u8]) -> (std::collections::HashMap, u32) { + let u16_at = |o: usize| u16::from_le_bytes([tiff[o], tiff[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([tiff[o], tiff[o + 1], tiff[o + 2], tiff[o + 3]]); + assert_eq!(&tiff[..4], b"II*\0"); + let at = u32_at(4) as usize; + let n = u16_at(at) as usize; + let tags = (0..n) + .map(|k| { + let e = at + 2 + k * 12; + let value = if u16_at(e + 2) == 3 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), value) + }) + .collect(); + (tags, u32_at(at + 2 + n * 12)) +} + +fn photo_session(exif: Vec) -> Session { + let mut s = session(8); + s.execute("file.fileInfo", json!({"copyright": "© Example"})).unwrap(); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif)); + Ok(()) + }) + .unwrap(); + s +} + +/// #7436: Save for Web's "All" writes the photo's EXIF, as Save a Copy does: upright +/// (Orientation 1), without the source's thumbnail and at the exported resolution. Every other +/// choice, "All Except Camera Info" included, leaves the camera data (and GPS) out. +#[test] +fn save_for_web_all_writes_the_photos_exif() { + let dir = tmp("exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": metadata, "path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + let exif = exif_app1(&all).expect("an EXIF APP1 segment"); + let camera = photocraft_algo::exif::read(&exif); + assert_eq!(camera.make.as_deref(), Some("TestMake")); + assert_eq!(camera.date_taken.as_deref(), Some("2024:05:01 10:30:00")); + let (tags, next) = ifd0(&exif); + assert_eq!(tags.get(&0x0112), Some(&1), "the exported pixels are already upright"); + assert!(tags.contains_key(&0x8825), "GPS kept"); + assert_eq!(next, 0, "the source's thumbnail is not linked"); + assert_eq!(photocraft_codecs::exif_resolution(&exif), Some((72.0, 72.0)), "the document's resolution, not the source's 300"); + assert!(decode(&format!("{dir}/all.jpg")).meta.xmp.unwrap_or_default().contains("Example")); + for m in ["none", "copyright", "copyrightAndContact", "allExceptCameraInfo"] { + let (bytes, r) = export(&mut s, m); + assert_eq!(exif_app1(&bytes), None, "{m}"); + assert_eq!(r["warnings"], json!([]), "{m}"); + } + assert_eq!(r["warnings"], json!([]), "it fits"); + let xmp = decode(&format!("{dir}/allExceptCameraInfo.jpg")).meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example"), "all the XMP"); + assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); +} + +#[test] +fn quick_export_all_writes_the_photos_exif() { + let dir = tmp("quick_exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": metadata})).unwrap(); + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + assert_eq!(photocraft_algo::exif::read(&exif_app1(&all).expect("EXIF")).make.as_deref(), Some("TestMake")); + assert_eq!(r["warnings"], json!([])); + assert_eq!(exif_app1(&export(&mut s, "copyright").0), None); + assert_eq!(exif_app1(&export(&mut s, "none").0), None); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// EXIF too large for its one segment is left out and said so; a truncated one doesn't panic. +#[test] +fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { + let dir = tmp("exif_bad"); + let mut big = camera_exif(); + big.resize(70_000, 0); + let mut s = photo_session(big); + let path = format!("{dir}/big.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(exif_app1(&std::fs::read(&path).unwrap()), None); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF (70000 bytes)")), "{r}"); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": "all"})).unwrap(); + let r = s.execute("file.export.quickExport", json!({"path": format!("{dir}/quick.jpg")})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF")), "{r}"); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); + + for cut in [3, 9, 40, 100] { + let mut s = photo_session(camera_exif()[..cut].to_vec()); + let path = format!("{dir}/cut{cut}.jpg"); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48)); + } +} + +#[test] +fn quick_export_exr_preserves_linear_hdr_and_alpha() { + use photocraft_codecs::{ChannelLayout, Format, Image, SampleType}; + let dir = tmp("quick_exr_hdr"); + let samples = [-0.25, 2.0, 0.125, 1.0, 4.0, 0.25, 0.5, 0.5]; + let image = Image::from_f32(2, 1, ChannelLayout::Rgba, &samples).unwrap(); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap(); + let doc = photocraft_io::import("hdr.exr", &bytes).unwrap().document; + let mut s = Session::new(); + s.add_document(doc, Some(format!("{dir}/source.psd"))); + for convert in [true, false] { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "exr", "quickExportLocation": "sameFolder", "convertToSrgb": convert})).unwrap(); + let result = s.execute("file.export.quickExport", json!({})).unwrap(); + assert_eq!(result["format"], "exr"); + let path = format!("{dir}/source.exr"); + assert_eq!(result["path"], path); + assert_eq!(result["bytes"].as_u64(), Some(std::fs::metadata(&path).unwrap().len())); + let decoded = decode(&path); + assert_eq!(decoded.sample_type(), SampleType::F32); + let actual = decoded.convert(ChannelLayout::Rgba, SampleType::F32).to_f32_samples().unwrap(); + for (got, expected) in actual.iter().zip(samples) { + assert!((got - expected).abs() < 0.0001, "EXR preserves scene values: {actual:?}"); + } + assert_eq!(actual.len(), samples.len()); + } + // The format preference must never silently overwrite the source EXR. + let own = format!("{dir}/source.exr"); + s.active_mut().unwrap().path = Some(own.clone()); + let before = std::fs::read(&own).unwrap(); + assert!(s.execute("file.export.quickExport", json!({})).is_err()); + assert_eq!(std::fs::read(&own).unwrap(), before); + s.execute("file.export.quickExport", json!({"path": format!("{dir}/explicit.exr")})).unwrap(); + assert_eq!(decode(&format!("{dir}/explicit.exr")).dimensions(), (2, 1)); +} diff --git a/crates/engine/tests/affinity.rs b/crates/engine/tests/it/affinity.rs similarity index 100% rename from crates/engine/tests/affinity.rs rename to crates/engine/tests/it/affinity.rs diff --git a/crates/engine/tests/artboard_export_names.rs b/crates/engine/tests/it/artboard_export_names.rs similarity index 100% rename from crates/engine/tests/artboard_export_names.rs rename to crates/engine/tests/it/artboard_export_names.rs diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs new file mode 100644 index 0000000000..7b72a6ff70 --- /dev/null +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -0,0 +1,151 @@ +//! #7669: strict ATN export must not replace a native fractional Crystallize cell size. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const CRYSTALLIZE: &str = "filter.pixelate.crystallize"; + +/// Procedural colours expose cell-boundary changes without relying on external images. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 1.0])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +/// Compare the actual flattened document rather than just the imported parameters. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Fractions remain valid native edits; strict integer ATN conversion must refuse them. +#[test] +fn fractional_cell_sizes_remain_native_and_are_refused_by_strict_export() { + let mut silently_exported = Vec::new(); + for depth in [8, 16, 32] { + for cell_size in [3.1, 5.4, 5.5, 5.6, 10.25, 299.9] { + let params = json!({"cellSize": cell_size}); + let action = Action::new("Fractional Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params).unwrap(); + let expected = pixels(&direct); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + assert_eq!(pixels(&native), expected); + + if cell_size == 5.4 { + let mut rounded = fixture(depth); + rounded.execute(CRYSTALLIZE, json!({"cellSize": 5})).unwrap(); + assert_ne!(pixels(&rounded), expected, "fixture must expose 5.4 vs 5: {depth}"); + } + if let Ok((bytes, skipped)) = atn_cmds::export("Crystallize", &[&action], &[], false) { + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); + let exported = imported.actions[0].steps[0].1["cellSize"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + let changed = pixels(&replay) != expected; + if cell_size == 5.4 { + assert!(changed, "baseline must expose the reported pixel loss: {depth}"); + } + silently_exported.push(format!("depth {depth}: {cell_size} -> {exported}, pixels changed: {changed}")); + } + } + } + assert!(silently_exported.is_empty(), "strict export silently accepted fractional cell sizes:\n{}", silently_exported.join("\n")); +} + +/// Integer sizes, bounds and omitted defaults retain their binary descriptor and playback. +#[test] +fn integer_cell_sizes_and_defaults_preserve_playback() { + for depth in [8, 16, 32] { + let mut cases = vec![Value::Null, json!({}), json!({"target": "pixels"}), json!({"cellSize": 5.0})]; + cases.extend([3, 5, 10, 100, 300].map(|cell_size| json!({"cellSize": cell_size}))); + for params in cases { + let action = Action::new("Integer Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params.clone()).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&native), pixels(&direct)); + + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + if let Some(cell_size) = params.get("cellSize").and_then(Value::as_f64) { + assert!(matches!(descriptor.get("ClSz"), Some(D::Integer(n)) if f64::from(*n) == cell_size)); + } + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, CRYSTALLIZE); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "depth {depth}: {params}"); + } + } +} + +/// Skipping is explicit and reports exactly which edits were lost, preserving supported order. +#[test] +fn skipping_fractional_cell_sizes_reports_action_and_step() { + let action = + Action::new("Mixed Crystallize", [5.0, 5.4, 10.0, 10.25].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect()); + let error = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2 of `Mixed Crystallize`"), "{error}"); + assert!(error.contains(CRYSTALLIZE) && error.contains("skipUnsupported"), "{error}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!( + skipped, + vec![ + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 1, id: CRYSTALLIZE.into() }, + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 3, id: CRYSTALLIZE.into() }, + ] + ); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), 2); + for (step, cell_size) in steps.iter().zip([5.0, 10.0]) { + assert_eq!(step.0, CRYSTALLIZE); + assert_eq!(step.1["cellSize"].as_f64(), Some(cell_size)); + } +} + +/// The plain event still cannot carry mask/channel targets or nonnumeric cell sizes. +#[test] +fn non_pixel_targets_and_malformed_fields_are_still_refused() { + for params in [ + json!({"cellSize": 5, "target": "mask"}), + json!({"cellSize": 5, "target": "quickMask"}), + json!({"cellSize": 5, "target": "channel"}), + json!({"cellSize": 5, "target": null}), + json!({"cellSize": null}), + json!({"cellSize": "5.4"}), + json!({"cellSize": 2147483648_i64}), + json!({"cellSize": -2147483649_i64}), + json!({"cellSize": 1e100}), + ] { + let action = Action::new("Unsupported Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + assert!(atn_cmds::export("Crystallize", &[&action], &[], false).is_err(), "{params}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Crystallize".into(), step: 0, id: CRYSTALLIZE.into() }]); + assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); + } +} diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs new file mode 100644 index 0000000000..b6bde88f7a --- /dev/null +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -0,0 +1,158 @@ +//! #7670: integer ATN Ripple amounts must never silently replace native fractions. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const RIPPLE: &str = "filter.distort.ripple"; + +/// Build a deterministic RGB image at the requested depth that exposes Ripple rounding. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 1.0])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +/// Flatten the active document to compare direct, native and ATN playback pixels. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Fractions are valid native edits, but the existing integer event cannot carry them. +#[test] +fn fractional_amounts_remain_native_and_are_refused_by_strict_export() { + let mut silently_exported = Vec::new(); + for depth in [8, 16, 32] { + for size in ["small", "medium", "large"] { + for amount in [81.4, -81.4, 0.5, -0.5, 998.9, -998.9] { + let params = json!({"amount": amount, "size": size}); + let action = Action::new("Fractional Ripple", vec![(RIPPLE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(RIPPLE, params).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Fractional Ripple"})).unwrap(); + assert_eq!(pixels(&direct), pixels(&native)); + + if amount == 81.4 { + let mut rounded = fixture(depth); + rounded.execute(RIPPLE, json!({"amount": 81, "size": size})).unwrap(); + assert_ne!(pixels(&direct), pixels(&rounded), "fixture must expose rounding: {depth}, {size}"); + } + if let Ok((bytes, skipped)) = atn_cmds::export("Ripple", &[&action], &[], false) { + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); + let exported = imported.actions[0].steps[0].1["amount"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Fractional Ripple"})).unwrap(); + let changed = pixels(&replay) != pixels(&direct); + if amount == 81.4 { + assert!(changed, "baseline must expose the reported pixel loss: {depth}, {size}"); + } + silently_exported.push(format!("depth {depth}, {size}: {amount} -> {exported}, pixels changed: {changed}")); + } + } + } + } + assert!(silently_exported.is_empty(), "strict export silently accepted fractional amounts:\n{}", silently_exported.join("\n")); +} + +/// Whole amounts and omitted defaults still round-trip through binary ATN and actual pixels. +#[test] +fn integer_amounts_sizes_and_defaults_preserve_playback() { + for depth in [8, 16, 32] { + let mut cases = vec![Value::Null, json!({}), json!({"target": "pixels"})]; + for size in ["small", "medium", "large"] { + for amount in [-999, -81, 0, 81, 999] { + cases.push(json!({"amount": amount, "size": size})); + } + } + for params in cases { + let action = Action::new("Integer Ripple", vec![(RIPPLE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(RIPPLE, params.clone()).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Integer Ripple"})).unwrap(); + assert_eq!(pixels(&native), pixels(&direct)); + + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + if let Some(amount) = params.get("amount").and_then(Value::as_i64) { + assert!(matches!(descriptor.get("Amnt"), Some(D::Integer(n)) if i64::from(*n) == amount)); + } + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, RIPPLE); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Integer Ripple"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "depth {depth}: {params}"); + } + } +} + +/// Opt-in skipping reports precisely the lost edits, keeping the supported steps in order. +#[test] +fn skipping_fractional_amounts_reports_action_and_step() { + let action = + Action::new("Mixed Ripple", [10.0, 81.4, -20.0, -0.5].into_iter().map(|amount| (RIPPLE.into(), json!({"amount": amount, "size": "small"}))).collect()); + let error = atn_cmds::export("Ripple", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2 of `Mixed Ripple`"), "{error}"); + assert!(error.contains(RIPPLE) && error.contains("skipUnsupported"), "{error}"); + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); + assert_eq!( + skipped, + vec![ + atn_cmds::Skipped { action: "Mixed Ripple".into(), step: 1, id: RIPPLE.into() }, + atn_cmds::Skipped { action: "Mixed Ripple".into(), step: 3, id: RIPPLE.into() }, + ] + ); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), 2); + for (step, amount) in steps.iter().zip([10.0, -20.0]) { + assert_eq!(step.0, RIPPLE); + assert_eq!(step.1["amount"].as_f64(), Some(amount)); + assert_eq!(step.1["size"], "small"); + } +} + +/// A dedicated Ripple mapping must retain the plain-event target restriction. +#[test] +fn non_pixel_targets_and_malformed_fields_are_still_refused() { + for params in [ + json!({"amount": 81, "target": "mask"}), + json!({"amount": 81, "target": "quickMask"}), + json!({"amount": 81, "target": "channel"}), + json!({"amount": 81, "target": null}), + json!({"amount": null}), + json!({"amount": "81.4"}), + json!({"amount": 2147483648_i64}), + json!({"amount": -2147483649_i64}), + json!({"amount": 1e100}), + json!({"amount": 81, "size": "unknown"}), + ] { + let action = Action::new("Unsupported Ripple", vec![(RIPPLE.into(), params.clone())]); + assert!(atn_cmds::export("Ripple", &[&action], &[], false).is_err(), "{params}"); + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Ripple".into(), step: 0, id: RIPPLE.into() }]); + assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); + } +} diff --git a/crates/engine/tests/it/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs new file mode 100644 index 0000000000..742359cf38 --- /dev/null +++ b/crates/engine/tests/it/atn_stroke_width.rs @@ -0,0 +1,167 @@ +//! #7671: exported Stroke widths must have the same effect as command execution. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +/// Synthetic colours and partial transparency make lost edge pixels observable at every depth. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 0.6])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 4, "width": 12, "height": 10})).unwrap(); + s +} + +/// Compare the flattened document, including alpha, rather than only imported JSON. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Direct execution, native playback and strict binary ATN playback must agree exactly. +fn check_pixels(widths: &[(f64, u32)]) { + let mut lost = Vec::new(); + for depth in [8, 16, 32] { + for location in ["inside", "center", "outside"] { + for &(width, effective) in widths { + let params = json!({"width": width, "color": "#d03060", "location": location, "opacity": 63, "mode": "multiply"}); + let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]); + let mut direct = fixture(depth); + let before = pixels(&direct); + direct.execute("edit.stroke", params.clone()).unwrap(); + let expected = pixels(&direct); + assert_ne!(expected, before, "ineffective fixture: {depth}, {params}"); + + let mut whole = fixture(depth); + let mut whole_params = params.clone(); + whole_params["width"] = json!(effective); + whole.execute("edit.stroke", whole_params).unwrap(); + assert_eq!(pixels(&whole), expected, "command width normalization: {depth}, {params}"); + if width == 2.4 { + let mut rounded = fixture(depth); + let mut rounded_params = params.clone(); + rounded_params["width"] = json!(2); + rounded.execute("edit.stroke", rounded_params).unwrap(); + assert_ne!(pixels(&rounded), expected, "2.4 must differ from 2: {depth}, {location}"); + } + + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + assert_eq!(pixels(&native), expected); + + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "edit.stroke"); + let imported_width = imported.actions[0].steps[0].1["width"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + if pixels(&replay) != expected { + lost.push(format!("depth {depth}, {location}, width {width} -> {imported_width}, expected {effective}")); + } + } + } + } + assert!(lost.is_empty(), "ATN changed Stroke pixels:\n{}", lost.join("\n")); +} + +/// In particular 2.4 renders as 3 px, while the old exporter serialized 2 px. +#[test] +fn fractional_stroke_width_preserves_pixels_at_every_depth_and_location() { + check_pixels(&[(0.5, 1), (1.01, 2), (2.01, 3), (2.4, 3), (2.5, 3), (2.6, 3), (3.1, 4)]); +} + +/// Existing whole-pixel strokes remain supported, with their original rendering. +#[test] +fn integer_stroke_width_controls_still_preserve_pixels() { + check_pixels(&[(1.0, 1), (2.0, 2), (3.0, 3), (4.0, 4)]); +} + +/// Export the command's bounded/default width as an integer, retaining the other options. +#[test] +fn descriptor_width_matches_defaults_limits_and_other_stroke_options() { + for (width, effective) in [ + (None, 1), + (Some(Value::Null), 1), + (Some(json!("2.4")), 1), + (Some(json!(true)), 1), + (Some(json!([])), 1), + (Some(json!({})), 1), + (Some(json!(-100)), 1), + (Some(json!(0)), 1), + (Some(json!(249.01)), 250), + (Some(json!(249.5)), 250), + (Some(json!(250)), 250), + (Some(json!(250.1)), 250), + (Some(json!(1e300)), 250), + ] { + let mut params = + json!({"color": "#d03060", "location": "outside", "opacity": 63, "mode": "multiply", "preserveTransparency": true, "target": "pixels"}); + if let Some(width) = width { + params["width"] = width; + } + let action = Action::new("Stroke", vec![("edit.stroke".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("Wdth"), Some(&D::Integer(effective)), "{params}"); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + let p = &imported.actions[0].steps[0].1; + assert_eq!(p["width"].as_f64(), Some(f64::from(effective))); + for key in ["color", "location", "preserveTransparency"] { + assert_eq!(p[key], params[key]); + } + // Import uses the canonical blend-mode label ("Multiply"); execution accepts both cases. + assert!(p["mode"].as_str().unwrap().eq_ignore_ascii_case(params["mode"].as_str().unwrap())); + assert_eq!(p["opacity"].as_f64(), Some(63.0)); + let mut direct = fixture(8); + direct.execute("edit.stroke", params).unwrap(); + let mut replay = fixture(8); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Stroke"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct)); + } + // A null parameter object is also a valid call to the command with its default width. + let action = Action::new("Default", vec![("edit.stroke".into(), Value::Null)]); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + assert_eq!(imported.actions[0].steps[0].1["width"].as_f64(), Some(1.0)); + let mut direct = fixture(8); + direct.execute("edit.stroke", Value::Null).unwrap(); + let mut replay = fixture(8); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Default"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct)); +} + +/// A custom Stroke converter must retain the plain-event mask/channel target guard. +#[test] +fn stroke_mask_and_channel_targets_are_still_refused_and_reported() { + for target in [json!("mask"), json!("quickMask"), json!({"channel": 0})] { + let action = Action::new("Targeted", vec![("edit.stroke".into(), json!({"width": 2.4, "target": target}))]); + assert!(atn_cmds::export("Widths", &[&action], &[], false).is_err()); + let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Targeted".into(), step: 0, id: "edit.stroke".into() }]); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + } +} diff --git a/crates/engine/tests/it/background_and_mask_depth.rs b/crates/engine/tests/it/background_and_mask_depth.rs new file mode 100644 index 0000000000..1fa400e244 --- /dev/null +++ b/crates/engine/tests/it/background_and_mask_depth.rs @@ -0,0 +1,102 @@ +use photocraft_color::SampleType; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +const STYLES: [&str; 10] = + ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "bevelEmboss", "satin"]; +const VECTOR_MASKS: [&str; 3] = ["layer.vectorMask.add", "layer.vectorMask.revealAll", "layer.vectorMask.hideAll"]; + +fn background_only() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s +} + +fn has_background(s: &Session) -> bool { + s.active().unwrap().doc.background().is_some() +} + +fn converts_and_undoes(cmd: &str, params: Value) { + let mut s = background_only(); + s.execute(cmd, params).unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert!(!has_background(&s), "{cmd} left a Background carrying the change"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "{cmd}: one undo restores the Background"); +} + +#[test] +fn layer_styles_and_vector_masks_turn_the_background_into_a_layer() { + for kind in STYLES { + converts_and_undoes(&format!("layer.layerStyle.{kind}"), json!({})); + } + for cmd in VECTOR_MASKS { + converts_and_undoes(cmd, json!({})); + } + converts_and_undoes("layer.layerStyle.replace", json!({"effects": [{"kind": "stroke"}]})); +} + +#[test] +fn clearing_a_style_keeps_the_background() { + let mut s = background_only(); + s.execute("layer.layerStyle.replace", json!({"effects": []})).unwrap(); + assert!(has_background(&s)); +} + +fn mask_sample(s: &Session) -> SampleType { + let st = s.active().unwrap(); + st.doc.walk().into_iter().find_map(|(_, _, l)| l.mask.as_ref().map(|m| m.surface.format().sample)).expect("a layer mask") +} + +fn painted(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [30, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn frame_and_remove_background_masks_match_the_document_depth() { + for depth in [16, 32] { + let want = if depth == 16 { SampleType::U16 } else { SampleType::F32 }; + for (cmd, params) in [ + ("layer.new.frameFromLayers", json!({})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12, "shape": "ellipse"})), + ("layer.removeBackground", json!({})), + ] { + let mut s = painted(depth); + s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); + assert_eq!(mask_sample(&s), want, "{cmd} {params} in a {depth}-bit document"); + } + } +} + +fn copied_style_over_background() -> (Session, u64, u64) { + let mut s = background_only(); + let bg = s.active().unwrap().doc.background().unwrap().id.0; + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap().0; + (s, bg, layer) +} + +#[test] +fn pasting_a_style_onto_the_background_turns_it_into_a_layer() { + let (mut s, bg, _) = copied_style_over_background(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": bg})).unwrap(); + assert!(!has_background(&s), "the pasted style landed on a Background"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "one undo restores the Background"); +} + +#[test] +fn pasting_a_style_with_no_layer_skips_the_background() { + let (mut s, bg, layer) = copied_style_over_background(); + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("layer.select", json!({"layer": layer, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + assert!(has_background(&s)); + assert!(s.active().unwrap().doc.background().unwrap().effects.items.is_empty()); +} diff --git a/crates/engine/tests/background_layer_consistency.rs b/crates/engine/tests/it/background_layer_consistency.rs similarity index 100% rename from crates/engine/tests/background_layer_consistency.rs rename to crates/engine/tests/it/background_layer_consistency.rs diff --git a/crates/engine/tests/brush_smoothing.rs b/crates/engine/tests/it/brush_smoothing.rs similarity index 100% rename from crates/engine/tests/brush_smoothing.rs rename to crates/engine/tests/it/brush_smoothing.rs diff --git a/crates/engine/tests/it/camera_raw_exposure_clamp.rs b/crates/engine/tests/it/camera_raw_exposure_clamp.rs new file mode 100644 index 0000000000..ee624f7e28 --- /dev/null +++ b/crates/engine/tests/it/camera_raw_exposure_clamp.rs @@ -0,0 +1,40 @@ +//! `filter.cameraRaw` must clamp `exposure` to its documented -5..5 range. Before the fix, +//! `raw_params` passed it through, so an exposure of 128 or more overflowed `2^exposure` to +//! infinity and a 32-bit layer was left with non-finite samples. + +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn camera_raw_clamps_exposure() { + let hi = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 255.0})).expect("params parse"); + assert_eq!(hi.exposure, 5.0, "exposure 255 must clamp to 5"); + let lo = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": -255.0})).expect("params parse"); + assert_eq!(lo.exposure, -5.0, "exposure -255 must clamp to -5"); + let mid = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 1.5})).expect("params parse"); + assert_eq!(mid.exposure, 1.5, "in-range exposure stays untouched"); +} + +#[test] +fn camera_raw_out_of_range_exposure_keeps_32_bit_layer_finite() { + for exposure in [255.0, -255.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("filter.cameraRaw", json!({"exposure": exposure})).unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); + let surf = layer.surface().unwrap(); + let mut bad = Vec::new(); + for y in 0..8 { + for x in 0..8 { + let px = surf.rgba(x, y); + if px.iter().any(|v| !v.is_finite()) { + bad.push(px); + } + } + } + assert!(bad.is_empty(), "exposure {exposure}: {} non-finite pixels, e.g. {:?}", bad.len(), bad.first()); + } +} diff --git a/crates/engine/tests/camera_raw_noise_clamp.rs b/crates/engine/tests/it/camera_raw_noise_clamp.rs similarity index 100% rename from crates/engine/tests/camera_raw_noise_clamp.rs rename to crates/engine/tests/it/camera_raw_noise_clamp.rs diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/it/clipping_semantics.rs similarity index 100% rename from crates/engine/tests/clipping_semantics.rs rename to crates/engine/tests/it/clipping_semantics.rs diff --git a/crates/engine/tests/close_document.rs b/crates/engine/tests/it/close_document.rs similarity index 100% rename from crates/engine/tests/close_document.rs rename to crates/engine/tests/it/close_document.rs diff --git a/crates/engine/tests/command_lookup.rs b/crates/engine/tests/it/command_lookup.rs similarity index 100% rename from crates/engine/tests/command_lookup.rs rename to crates/engine/tests/it/command_lookup.rs diff --git a/crates/engine/tests/it/convert_keeps_references.rs b/crates/engine/tests/it/convert_keeps_references.rs new file mode 100644 index 0000000000..a9859d912e --- /dev/null +++ b/crates/engine/tests/it/convert_keeps_references.rs @@ -0,0 +1,47 @@ +use photocraft_doc::{LayerId, SliceOrigin}; +use photocraft_engine::Session; +use serde_json::json; + +fn art() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Art"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({})).unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "v", "layer": id.0, "type": "visibility"}]})).unwrap(); + (s, id) +} + +fn references_point_at(s: &Session, id: LayerId) -> bool { + let doc = &s.active().unwrap().doc; + let slice = doc.slices.list.iter().any(|sl| sl.origin == SliceOrigin::Layer && sl.layer == Some(id)); + let comps = doc.layer_comps.iter().all(|c| c.states.iter().any(|st| st.layer == id)); + let vars = doc.variables.defs.iter().all(|d| d.layer == id); + slice && comps && vars +} + +#[test] +fn converting_a_layer_keeps_its_slice_comps_and_variables() { + for cmd in ["layer.smartObjects.convertToSmartObject", "filter.convertForSmartFilters"] { + let (mut s, id) = art(); + s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + s.execute(cmd, json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + assert!(references_point_at(&s, smart), "{cmd} left references on the old layer"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(references_point_at(&s, id), "{cmd}: undo restores the original references"); + } +} + +#[test] +fn a_comp_does_not_put_the_converted_layers_style_on_the_smart_object() { + let (mut s, _) = art(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + let r = s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + let comp = r["comp"].as_u64().unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute("layerComp.apply", json!({"comp": comp})).unwrap(); + assert!(s.active().unwrap().doc.layer(smart).unwrap().effects.items.is_empty(), "the style stays inside the smart object"); +} diff --git a/crates/engine/tests/it/corpus_atn.rs b/crates/engine/tests/it/corpus_atn.rs new file mode 100644 index 0000000000..afc11cbc2a --- /dev/null +++ b/crates/engine/tests/it/corpus_atn.rs @@ -0,0 +1,132 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` set under +//! `corpus/atn/` loads through `actions.load`, exports through `actions.save`, and the export +//! loads back to the same actions. Steps without a PhotoCraft equivalent are counted against a +//! ceiling, so a mapping regression (or a new unmapped event in the corpus) shows up here. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_engine::Session; +use photocraft_engine::actions_cmds::Action; +use serde_json::{Value, json}; + +fn collect(dir: &Path, out: &mut Vec) { + // An unreadable folder fails the test rather than shrinking the corpus. + let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + for e in rd { + let p = e.unwrap_or_else(|e| panic!("{}: {e}", dir.display())).path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { + out.push(p); + } + } +} + +/// Sets in the corpus, and the most steps that may still be kept Photoshop steps across all of +/// them (2026-10-12: 517 of 5 296; 404 are generative AI (`generativeUpscale`, `syntheticFill`, +/// `syntheticGenHarmonize`: deferred by decision) and `AdobeScriptAutomation` scripts, the rest +/// shape layers with strokes, snapshots, smart-filter and channel edits, frame animation). +/// Raise the first, lower the second, never the reverse. +const FLOOR_FILES: usize = 16; +const CEILING_UNSUPPORTED: usize = 540; + +/// Save writes a compound `layer.setProps` (name, opacity, blend) as one Set per property, because +/// Photoshop applies only the rename of a combined one (#8304); loading gives one step per +/// property. Merge consecutive `layer.setProps` steps of one target so both sides compare as the +/// same actions. +fn merge_set_props(actions: &[Action]) -> Vec { + actions + .iter() + .map(|a| { + let mut out = a.clone(); + out.steps.clear(); + out.disabled.clear(); + out.dialogs.clear(); + for (i, (id, params)) in a.steps.iter().enumerate() { + let merged = match (out.steps.last_mut(), params.as_object()) { + (Some((last_id, Value::Object(last))), Some(p)) + if id == "layer.setProps" + && last_id == id + && last.get("layer") == p.get("layer") + && p.keys().all(|k| k == "layer" || !last.contains_key(k)) => + { + last.extend(p.iter().map(|(k, v)| (k.clone(), v.clone()))); + true + } + _ => false, + }; + if !merged { + out.steps.push((id.clone(), params.clone())); + } + let at = out.steps.len() - 1; + if a.disabled.contains(&i) { + out.disabled.insert(at); + } + if a.dialogs.contains(&i) { + out.dialogs.insert(at); + } + } + out + }) + .collect() +} + +#[test] +fn atn_corpus_loads_saves_and_reloads() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + let (mut steps, mut unsupported) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let data = photocraft_engine::paint::tile::b64_encode(&std::fs::read(p).unwrap()); + let mut s = Session::new(); + let loaded = match s.execute("actions.load", json!({"data": data})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: load: {e}")); + continue; + } + }; + let n_steps = loaded["steps"].as_u64().unwrap_or(0) as usize; + let n_unsupported = loaded["unsupported"].as_array().map_or(0, Vec::len); + let events: Vec = + loaded["unsupported"].as_array().map_or(Vec::new(), |u| u.iter().filter_map(|x| x["event"].as_str().map(String::from)).collect()); + steps += n_steps; + unsupported += n_unsupported; + println!("{name}: {} actions, {n_steps} steps, {n_unsupported} kept Photoshop steps {events:?}", loaded["actions"].as_array().map_or(0, Vec::len)); + let first = s.actions.list.clone(); + let saved = match s.execute("actions.save", json!({"set": loaded["set"]})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: save: {e}")); + continue; + } + }; + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap_or("")).unwrap_or_default(); + let mut t = Session::new(); + match t.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes)})) { + Ok(_) if merge_set_props(&t.actions.list) == merge_set_props(&first) => {} + Ok(_) => { + // Name the first step that differs, so a mapping asymmetry is quick to find. + let detail = first + .iter() + .zip(&t.actions.list) + .flat_map(|(a, b)| a.steps.iter().zip(&b.steps).enumerate().map(move |(i, (x, y))| (a.name.as_str(), i, x, y))) + .find(|(_, _, x, y)| x != y) + .map(|(action, i, x, y)| format!("`{action}` step {}: {} {} → {} {}", i + 1, x.0, x.1, y.0, y.1)) + .unwrap_or_else(|| "the actions differ in count, flags or names".into()); + failures.push(format!("{name}: the saved set loads to different actions: {detail}")); + } + Err(e) => failures.push(format!("{name}: reload: {e}")), + } + } + println!("{} files, {steps} steps, {unsupported} kept Photoshop steps", files.len()); + assert!(files.len() >= FLOOR_FILES, "only {} .atn files; the corpus floor is {FLOOR_FILES}", files.len()); + assert!(unsupported <= CEILING_UNSUPPORTED, "{unsupported} steps have no PhotoCraft equivalent; the ceiling is {CEILING_UNSUPPORTED}"); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/it/crop_content_aware.rs similarity index 100% rename from crates/engine/tests/crop_content_aware.rs rename to crates/engine/tests/it/crop_content_aware.rs diff --git a/crates/engine/tests/current_affinity_artboards.rs b/crates/engine/tests/it/current_affinity_artboards.rs similarity index 97% rename from crates/engine/tests/current_affinity_artboards.rs rename to crates/engine/tests/it/current_affinity_artboards.rs index e3b27e856b..8348b4065f 100644 --- a/crates/engine/tests/current_affinity_artboards.rs +++ b/crates/engine/tests/it/current_affinity_artboards.rs @@ -1,7 +1,7 @@ //! Artboards recognized from current .af properties move with their children and undo together. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "../../io/tests/support/current_affinity.rs"] +#[path = "../../../io/tests/support/current_affinity.rs"] mod fixture; use fixture::{Marker, document}; diff --git a/crates/engine/tests/duplicate_layer_comps.rs b/crates/engine/tests/it/duplicate_layer_comps.rs similarity index 100% rename from crates/engine/tests/duplicate_layer_comps.rs rename to crates/engine/tests/it/duplicate_layer_comps.rs diff --git a/crates/engine/tests/it/duplicate_slices.rs b/crates/engine/tests/it/duplicate_slices.rs new file mode 100644 index 0000000000..5f5d21c621 --- /dev/null +++ b/crates/engine/tests/it/duplicate_slices.rs @@ -0,0 +1,35 @@ +use photocraft_doc::SliceOrigin; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn sliced() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "logo"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({"url": "https://example.com", "alt": "logo"})).unwrap(); + s +} + +fn layer_slice(s: &mut Session) -> Option { + let list = s.execute("slice.list", json!({})).unwrap(); + list["slices"].as_array().unwrap().iter().find(|sl| sl["origin"] == "layer").cloned() +} + +#[test] +fn duplicating_a_document_keeps_its_layer_based_slices() { + for cmd in ["image.duplicate", "history.newDocument"] { + let mut s = sliced(); + s.execute(cmd, json!({})).unwrap(); + let st = s.active().unwrap(); + let logo = st.doc.walk().into_iter().find(|(_, _, l)| l.name == "logo").unwrap().2.id; + let slices: Vec<_> = st.doc.slices.list.iter().filter(|sl| sl.origin == SliceOrigin::Layer).collect(); + assert_eq!(slices.len(), 1, "{cmd}"); + assert_eq!(slices[0].layer, Some(logo), "{cmd}"); + assert_eq!((slices[0].url.as_str(), slices[0].alt.as_str()), ("https://example.com", "logo"), "{cmd}"); + let before = layer_slice(&mut s).unwrap()["rect"].clone(); + s.execute("layer.translate", json!({"dx": 5, "dy": 3})).unwrap(); + let after = layer_slice(&mut s).unwrap_or_else(|| panic!("{cmd} lost the slice after a move"))["rect"].clone(); + assert_ne!(before, after, "{cmd}: the slice no longer follows its layer"); + } +} diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/it/duplicate_variables.rs similarity index 100% rename from crates/engine/tests/duplicate_variables.rs rename to crates/engine/tests/it/duplicate_variables.rs diff --git a/crates/engine/tests/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs similarity index 100% rename from crates/engine/tests/empty_selection_combine.rs rename to crates/engine/tests/it/empty_selection_combine.rs diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/it/eraser_locked.rs similarity index 100% rename from crates/engine/tests/eraser_locked.rs rename to crates/engine/tests/it/eraser_locked.rs diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/it/explicit_layer_target.rs similarity index 100% rename from crates/engine/tests/explicit_layer_target.rs rename to crates/engine/tests/it/explicit_layer_target.rs diff --git a/crates/engine/tests/it/export_shape_strokes.rs b/crates/engine/tests/it/export_shape_strokes.rs new file mode 100644 index 0000000000..0875f4bfc0 --- /dev/null +++ b/crates/engine/tests/it/export_shape_strokes.rs @@ -0,0 +1,105 @@ +//! Regression coverage for #8341: canvas resizing must scale shape strokes too. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{ + Session, + web_cmds::{WebSettings, web_document}, +}; +use serde_json::json; + +fn shape_session(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + (s, LayerId(id)) +} + +#[test] +fn web_downscale_keeps_stroke_only_shape_proportions_at_every_depth() { + for depth in [8, 16, 32] { + let (s, id) = shape_session(depth); + let original = &s.active().unwrap().doc; + for params in [json!({"percent": 25}), json!({"width": 256, "height": 256})] { + let (scaled, sx, sy) = web_document(original, ¶ms, &WebSettings::default()).unwrap(); + assert_eq!((scaled.size.width, scaled.size.height), (256, 256)); + assert_eq!((sx, sy), (0.25, 0.25)); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 8.0, "{depth}-bit stroke"); + let pixels = photocraft_compose::flatten(&scaled); + let alpha = |x: usize| pixels.px[128 * 256 + x][3]; + assert!(alpha(62) > 0.99, "scaled stroke is visible"); + assert!(alpha(52) < 0.01, "stroke must not retain its original width"); + assert!(alpha(128) < 0.01, "no fill is introduced"); + } + let LayerContent::Shape(sh) = &original.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 32.0, "export leaves the source untouched"); + } +} + +#[test] +fn image_size_scales_strokes_once_and_undo_restores_them() { + let (mut s, id) = shape_session(8); + s.execute("shape.edit", json!({"layer": id.0, "stroke": {"dashes": [2, 1], "dashOffset": 0.5}})).unwrap(); + let before = s.active().unwrap().doc.layer(id).unwrap().clone(); + s.execute("image.imageSize", json!({"width": 512, "height": 512})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); + assert_eq!(sh.stroke.as_ref().unwrap().dashes, vec![2.0, 1.0], "dash lengths stay in stroke-width units"); + assert_eq!(sh.stroke.as_ref().unwrap().dash_offset, 0.5); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap(), &before); + s.execute("edit.redo", json!({})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); +} + +#[test] +fn grouped_strokes_scale_up_and_unchanged_size_is_a_noop() { + let (mut s, id) = shape_session(8); + s.edit("Group", |doc, _| { + let children = std::mem::take(&mut doc.layers); + doc.layers.push(photocraft_doc::Layer::group("Outer", vec![photocraft_doc::Layer::group("Inner", children)])); + Ok(()) + }) + .unwrap(); + let original = &s.active().unwrap().doc; + for (width, expected) in [(1024, 32.0), (1280, 40.0), (256, 8.0)] { + let (scaled, _, _) = web_document(original, &json!({"width": width}), &WebSettings::default()).unwrap(); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, expected); + } +} + +#[test] +fn png_export_contains_the_scaled_stroke_and_keeps_the_source_document() { + let (mut s, _) = shape_session(8); + let before = s.active().unwrap().doc.clone(); + let mut bytes = Vec::new(); + photocraft_engine::web_cmds::save_for_web_with_writer( + &mut s, + &json!({"format": "png24", "path": "stroke.png", "width": 256, "height": 256}), + &mut |_, encoded| { + bytes = encoded.to_vec(); + Ok(()) + }, + ) + .unwrap(); + let image = photocraft_codecs::decode(&bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(image.dimensions(), (256, 256)); + let alpha = |x: usize| image.data()[(128 * 256 + x) * 4 + 3]; + assert_eq!(alpha(62), 255); + assert_eq!(alpha(52), 0); + assert_eq!(alpha(128), 0); + assert_eq!(s.active().unwrap().doc, before); +} diff --git a/crates/engine/tests/fill_lock_transparency.rs b/crates/engine/tests/it/fill_lock_transparency.rs similarity index 100% rename from crates/engine/tests/fill_lock_transparency.rs rename to crates/engine/tests/it/fill_lock_transparency.rs diff --git a/crates/engine/tests/filter_locked.rs b/crates/engine/tests/it/filter_locked.rs similarity index 100% rename from crates/engine/tests/filter_locked.rs rename to crates/engine/tests/it/filter_locked.rs diff --git a/crates/engine/tests/it/finite_command_params.rs b/crates/engine/tests/it/finite_command_params.rs new file mode 100644 index 0000000000..0350d74493 --- /dev/null +++ b/crates/engine/tests/it/finite_command_params.rs @@ -0,0 +1,154 @@ +//! #6424: rejecting an overflowing f32 parameter must leave a saveable document and history. +use photocraft_engine::{EngineError, Session}; +use serde_json::{Value, json}; + +fn fresh(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +fn rejected(cases: Vec<(&str, Value)>) { + let mut failures = Vec::new(); + for (id, p) in cases { + let mut s = fresh("rgb", 8); + let before = saved(&s); + let undo = s.active().unwrap().history.undo_label().map(str::to_owned); + let result = s.execute(id, p.clone()); + let save = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()); + if !matches!(result, Err(EngineError::BadParams { .. })) { + failures.push(format!("{id} {p}: result={result:?}, save error={:?}", save.as_ref().err())); + continue; + } + assert_eq!(save.unwrap(), before, "rejected {id} must not change the document"); + assert_eq!(s.active().unwrap().history.undo_label(), undo.as_deref(), "rejected {id} must not add history"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn overflowing_layer_styles_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for value in [1e39, -1e39] { + for (kind, key) in [ + ("dropShadow", "angle"), + ("innerShadow", "angle"), + ("outerGlow", "size"), + ("innerGlow", "size"), + ("stroke", "size"), + ("satin", "angle"), + ("bevelEmboss", "depth"), + ("gradientOverlay", "angle"), + ("patternOverlay", "phaseX"), + ] { + let id = match kind { + "dropShadow" => "layer.layerStyle.dropShadow", + "innerShadow" => "layer.layerStyle.innerShadow", + "outerGlow" => "layer.layerStyle.outerGlow", + "innerGlow" => "layer.layerStyle.innerGlow", + "stroke" => "layer.layerStyle.stroke", + "satin" => "layer.layerStyle.satin", + "bevelEmboss" => "layer.layerStyle.bevelEmboss", + "gradientOverlay" => "layer.layerStyle.gradientOverlay", + _ => "layer.layerStyle.patternOverlay", + }; + let params = json!({key: value}); + cases.push((id, params.clone())); + cases.push(("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]}))); + } + cases.push(("layer.layerStyle.colorOverlay", json!({"color": [value, 0, 0]}))); + } + rejected(cases); +} + +#[test] +fn overflowing_document_colors_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for id in ["layer.newFillLayer.solidColor", "channel.new", "channel.newSpot", "layer.new.artboard", "count.newGroup"] { + for value in [1e39, -1e39] { + for component in 0..4 { + let mut color = [0.5; 4]; + color[component] = value; + cases.push((id, json!({"color": color}))); + } + } + } + rejected(cases); +} + +#[test] +fn overflowing_type_positions_and_colors_are_rejected_before_mutation() { + rejected(vec![ + ("type.create", json!({"text": "hello", "x": 1e39})), + ("type.create", json!({"text": "hello", "y": -1e39})), + ("type.create", json!({"text": "hello", "color": [1e39, 0, 0]})), + ]); +} + +#[test] +fn valid_color_and_style_edits_still_save_and_undo_at_each_depth() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = fresh(mode, depth); + let before = saved(&s); + s.execute("layer.layerStyle.dropShadow", json!({"angle": -45, "color": [0.1, 0.3, 0.8]})).unwrap(); + let edited = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), edited); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + let bytes = saved(&s); + photocraft_format::load_from_bytes(&bytes).unwrap(); + } + } +} + +#[test] +fn effect_snapshots_cannot_bypass_numeric_validation() { + let mut fx = serde_json::to_value(photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap()).unwrap(); + fx["DropShadow"]["angle"] = json!(1e39); + rejected(vec![( + "layer.layerStyle.replace", + json!({"effects": [ + {"kind": "colorOverlay", "params": {"color": "#00ff00"}}, + {"kind": "dropShadow", "fx": fx, "params": {}} + ]}), + )]); +} + +#[test] +fn direct_effect_helpers_do_not_introduce_infinity() { + let original = photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap(); + for p in [json!({"angle": 1e39}), json!({"color": [0, -1e39, 0]})] { + assert!(photocraft_engine::layer_style::effect_from_params("dropShadow", &p).is_none()); + let mut edited = original.clone(); + photocraft_engine::layer_style::overlay_effect(&mut edited, &p); + assert_eq!(edited, original); + } +} + +#[test] +fn bad_background_color_does_not_partially_change_tool_colors() { + let mut s = fresh("rgb", 32); + let before = (s.tools.foreground, s.tools.background); + let result = s.execute("tools.setColors", json!({"foreground": "#40a0c0", "background": [0, 1e39, 0]})); + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + assert_eq!((s.tools.foreground, s.tools.background), before); +} + +#[test] +fn finite_hdr_colors_are_preserved_without_unit_clamping() { + let mut s = fresh("rgb", 32); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": [4.0, -0.5, 0.25]})).unwrap()["layer"].as_u64().unwrap(); + let bytes = saved(&s); + let reopened = photocraft_format::load_from_bytes(&bytes).unwrap(); + let layer = reopened.layer(photocraft_doc::LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Solid(c)) = &layer.content else { panic!("expected a solid fill") }; + assert_eq!(c.to_rgb(), [4.0, -0.5, 0.25]); +} diff --git a/crates/engine/tests/flatten_background.rs b/crates/engine/tests/it/flatten_background.rs similarity index 100% rename from crates/engine/tests/flatten_background.rs rename to crates/engine/tests/it/flatten_background.rs diff --git a/crates/engine/tests/gallery_colours.rs b/crates/engine/tests/it/gallery_colours.rs similarity index 100% rename from crates/engine/tests/gallery_colours.rs rename to crates/engine/tests/it/gallery_colours.rs diff --git a/crates/engine/tests/gauss_small_sigma.rs b/crates/engine/tests/it/gauss_small_sigma.rs similarity index 100% rename from crates/engine/tests/gauss_small_sigma.rs rename to crates/engine/tests/it/gauss_small_sigma.rs diff --git a/crates/engine/tests/it/gradient_overlay_strokes.rs b/crates/engine/tests/it/gradient_overlay_strokes.rs new file mode 100644 index 0000000000..e233f8984d --- /dev/null +++ b/crates/engine/tests/it/gradient_overlay_strokes.rs @@ -0,0 +1,89 @@ +//! A Gradient Overlay spans one gradient over the layer's current bounds, so a stroke that grows +//! the layer re-lays the gradient out over all of it (#4053). The damage a stroke reports must +//! cover that, or a canvas that redraws only the damage keeps the old gradient around the earlier +//! strokes: one gradient per stroke instead of one over the whole layer. + +use photocraft_engine::Session; +use serde_json::json; + +/// What a canvas that redraws only each edit's damage shows: the cold render before the edit, +/// with the edit's damage (grown by the effects' reach, as the canvas grows it) rendered again. +fn redraw_damage(shown: &mut photocraft_compose::Buffer, s: &Session) { + let st = s.active().unwrap(); + let reach = st.doc.layers.iter().filter(|l| photocraft_compose::effects::has_effects(l)).map(photocraft_compose::effects::margin).max().unwrap_or(0); + let damage = st.last_damage.expect("a stroke reports bounded damage").inflate(reach).intersect(&st.doc.bounds()); + let (w, h) = (st.doc.size.width, st.doc.size.height); + let patch = photocraft_compose::render_reduced_damage(&st.doc, w, h, damage); + for y in patch.rect.y0..patch.rect.y1 { + for x in patch.rect.x0..patch.rect.x1 { + let i = (y * w as i32 + x) as usize; + shown.px[i] = patch.get(x, y); + } + } +} + +fn session(effect: &str, params: serde_json::Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 160, "height": 80, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute(effect, params).unwrap(); + s +} + +const GREY: &str = "#808080"; + +/// Each stroke redrawn over the last cold render equals a cold render after it. +fn strokes_redraw_like_a_cold_render(mut s: Session, strokes: &[serde_json::Value]) { + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + for (i, p) in strokes.iter().enumerate() { + let st = s.active().unwrap(); + let mut shown = photocraft_compose::render(&st.doc, st.doc.bounds()); + s.execute("paint.stroke", p.clone()).unwrap(); + redraw_damage(&mut shown, &s); + let st = s.active().unwrap(); + let cold = photocraft_compose::render(&st.doc, st.doc.bounds()); + let worst = shown.px.iter().zip(&cold.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "after stroke {i} the redrawn canvas differs from a cold render by {worst}"); + } +} + +#[test] +fn a_gradient_overlay_is_laid_out_again_after_each_stroke() { + let s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + strokes_redraw_like_a_cold_render( + s, + &[ + // Far from the first: the layer grows to the right and down. + json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY}), + // Up and to the left of both. + json!({"points": [[6, 6], [10, 8]], "size": 6, "hardness": 1.0, "color": GREY}), + // Erasing the far stroke shrinks the layer again. + json!({"points": [[110, 40], [150, 74]], "size": 40, "hardness": 1.0, "erase": true}), + ], + ); +} + +#[test] +fn a_stroke_inside_the_layer_keeps_its_own_damage() { + let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + // Within the layer's bounds: the gradient's frame stays, so only the stroke repaints. + let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap(); + let d = &r["damage"]; + let reported = photocraft_geom::Rect::new( + d[0].as_i64().unwrap() as i32, + d[1].as_i64().unwrap() as i32, + (d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32, + (d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32, + ); + assert_eq!(s.active().unwrap().last_damage, Some(reported)); +} + +#[test] +fn effects_not_laid_out_over_the_layer_keep_the_stroke_damage() { + let mut s = session("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})); + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + s.execute("paint.stroke", json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + let d = s.active().unwrap().last_damage.unwrap(); + assert!(d.x0 >= 100, "a Color Overlay repaints only the stroke: {d:?}"); +} diff --git a/crates/engine/tests/guide_layout_limit.rs b/crates/engine/tests/it/guide_layout_limit.rs similarity index 100% rename from crates/engine/tests/guide_layout_limit.rs rename to crates/engine/tests/it/guide_layout_limit.rs diff --git a/crates/engine/tests/guide_layout_preview.rs b/crates/engine/tests/it/guide_layout_preview.rs similarity index 100% rename from crates/engine/tests/guide_layout_preview.rs rename to crates/engine/tests/it/guide_layout_preview.rs diff --git a/crates/engine/tests/hdr_gamma_direction.rs b/crates/engine/tests/it/hdr_gamma_direction.rs similarity index 100% rename from crates/engine/tests/hdr_gamma_direction.rs rename to crates/engine/tests/it/hdr_gamma_direction.rs diff --git a/crates/engine/tests/it/huge_doc_flatten.rs b/crates/engine/tests/it/huge_doc_flatten.rs new file mode 100644 index 0000000000..fd83c1169d --- /dev/null +++ b/crates/engine/tests/it/huge_doc_flatten.rs @@ -0,0 +1,30 @@ +//! Trim and Duplicate › Merged Layers Only flatten the document; on a canvas over the compositor's +//! render cap the flatten comes back empty, and both must report it instead of panicking (#7223). + +use photocraft_engine::Session; +use serde_json::json; + +/// 1.2e9 px: within File › New's per-side limit, over `MAX_RENDER_PIXELS` (2^30). +fn huge() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + s +} + +#[test] +fn trim_on_a_document_too_large_to_flatten_is_an_error() { + for based in ["topLeft", "bottomRight", "transparent"] { + let mut s = huge(); + let err = s.execute("image.trim", json!({"basedOn": based})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{based}: {err}"); + assert_eq!(s.active().unwrap().doc.size.width, 40000, "{based}: the document is untouched"); + } +} + +#[test] +fn merged_duplicate_of_a_document_too_large_to_flatten_is_an_error() { + let mut s = huge(); + let err = s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{err}"); + assert_eq!(s.documents().len(), 1, "no half-built copy is added"); +} diff --git a/crates/engine/tests/jpeg_exif_resolution.rs b/crates/engine/tests/it/jpeg_exif_resolution.rs similarity index 100% rename from crates/engine/tests/jpeg_exif_resolution.rs rename to crates/engine/tests/it/jpeg_exif_resolution.rs diff --git a/crates/engine/tests/keyboard_shortcut_bulk.rs b/crates/engine/tests/it/keyboard_shortcut_bulk.rs similarity index 100% rename from crates/engine/tests/keyboard_shortcut_bulk.rs rename to crates/engine/tests/it/keyboard_shortcut_bulk.rs diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/it/kra.rs similarity index 96% rename from crates/engine/tests/kra.rs rename to crates/engine/tests/it/kra.rs index 8bdf10531c..783c5fb28f 100644 --- a/crates/engine/tests/kra.rs +++ b/crates/engine/tests/it/kra.rs @@ -1,7 +1,7 @@ //! Krita documents opened through the session: read-only source, 16-bit layers, editable //! layers with history, and saving as OpenRaster or native. The files come from //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with -//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test kra -- --ignored --nocapture`. +//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test it -- kra:: --ignored --nocapture`. use photocraft_engine::Session; use serde_json::json; @@ -24,7 +24,7 @@ fn krita_documents_open_read_only_and_stay_editable() { assert!(session.execute("file.save", json!({})).is_err()); // Krita's merged image is the composite (the corpus files carry no ICC profile, so both - // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file. + // sides are sRGB); crates/io/tests/it/krita_corpus.rs compares every file. let ours = photocraft_compose::flatten(&session.active().unwrap().doc); let zip_bytes = std::fs::read(&path).unwrap(); let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap(); diff --git a/crates/engine/tests/kys_import.rs b/crates/engine/tests/it/kys_import.rs similarity index 100% rename from crates/engine/tests/kys_import.rs rename to crates/engine/tests/it/kys_import.rs diff --git a/crates/engine/tests/layer_delete_last.rs b/crates/engine/tests/it/layer_delete_last.rs similarity index 100% rename from crates/engine/tests/layer_delete_last.rs rename to crates/engine/tests/it/layer_delete_last.rs diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs similarity index 100% rename from crates/engine/tests/layer_mask_depth.rs rename to crates/engine/tests/it/layer_mask_depth.rs diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/it/layer_props_several.rs similarity index 100% rename from crates/engine/tests/layer_props_several.rs rename to crates/engine/tests/it/layer_props_several.rs diff --git a/crates/engine/tests/layer_range_panel_order.rs b/crates/engine/tests/it/layer_range_panel_order.rs similarity index 94% rename from crates/engine/tests/layer_range_panel_order.rs rename to crates/engine/tests/it/layer_range_panel_order.rs index 0e6199d539..d442d35198 100644 --- a/crates/engine/tests/layer_range_panel_order.rs +++ b/crates/engine/tests/it/layer_range_panel_order.rs @@ -20,6 +20,7 @@ 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(); let d = s.execute("layer.new.layer", json!({"name": "D"})).unwrap()["layer"].as_u64().unwrap(); for (anchor, target, wanted) in diff --git a/crates/engine/tests/live_line_transform.rs b/crates/engine/tests/it/live_line_transform.rs similarity index 100% rename from crates/engine/tests/live_line_transform.rs rename to crates/engine/tests/it/live_line_transform.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 0000000000..cd44a7a946 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,80 @@ +//! The crate's integration tests, linked into one test binary. + +mod affinity; +mod artboard_export_names; +mod atn_crystallize_cell_size; +mod atn_ripple_amount; +mod atn_stroke_width; +mod background_and_mask_depth; +mod background_layer_consistency; +mod brush_smoothing; +mod camera_raw_exposure_clamp; +mod camera_raw_noise_clamp; +mod clipping_semantics; +mod close_document; +mod command_lookup; +mod convert_keeps_references; +mod corpus_atn; +mod crop_content_aware; +mod current_affinity_artboards; +mod duplicate_layer_comps; +mod duplicate_slices; +mod duplicate_variables; +mod empty_selection_combine; +mod eraser_locked; +mod explicit_layer_target; +mod export_shape_strokes; +mod fill_lock_transparency; +mod filter_locked; +mod finite_command_params; +mod flatten_background; +mod gallery_colours; +mod gauss_small_sigma; +mod gradient_overlay_strokes; +mod guide_layout_limit; +mod guide_layout_preview; +mod hdr_gamma_direction; +mod huge_doc_flatten; +mod jpeg_exif_resolution; +mod keyboard_shortcut_bulk; +mod kra; +mod kys_import; +mod layer_delete_last; +mod layer_mask_depth; +mod layer_props_several; +mod layer_range_panel_order; +mod live_line_transform; +mod merge_transparent_bounds; +mod missing_layer_error; +mod nonfinite_style; +mod open_identity; +mod path_blur_speed_clamp; +mod path_transform_target; +mod pdn; +mod photoshop_oracles; +mod pixel_lock_edits; +mod precheck; +mod prefs_usage; +mod rasterize_psd_reopen; +mod rasterize_selected_layers; +mod rendering_preferences; +mod save_keeps_encoding; +mod selection_layer_commands; +mod shortcut_sets; +mod shortcuts_headless; +mod smart_clipboard_sources; +mod smart_duplicates; +mod smart_filter_canvas_geometry; +mod smart_object_tones; +mod smart_psd; +mod style_and_align; +mod svg_smart_object; +mod transform_budget_unclamped; +mod type_format_targets; +mod type_postscript_font; +mod undo_keeps_active_layer; +mod unicode_colors; +mod unsupported_smart_filters; +mod wand_transparent_copy; +mod working_rgb_profile; +mod xmp_charref; diff --git a/crates/engine/tests/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs similarity index 100% rename from crates/engine/tests/merge_transparent_bounds.rs rename to crates/engine/tests/it/merge_transparent_bounds.rs diff --git a/crates/engine/tests/missing_layer_error.rs b/crates/engine/tests/it/missing_layer_error.rs similarity index 100% rename from crates/engine/tests/missing_layer_error.rs rename to crates/engine/tests/it/missing_layer_error.rs diff --git a/crates/engine/tests/it/nonfinite_style.rs b/crates/engine/tests/it/nonfinite_style.rs new file mode 100644 index 0000000000..994f3dbe6e --- /dev/null +++ b/crates/engine/tests/it/nonfinite_style.rs @@ -0,0 +1,56 @@ +//! Character style numbers that overflow `f32` are refused, so the document stays saveable. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.execute("type.create", json!({"text": "Hi", "size": 24})).unwrap()["layer"].as_u64().unwrap(); + (s, id) +} + +#[test] +fn out_of_range_style_numbers_are_refused() { + for key in ["leading", "baselineShift", "horizontalScale", "verticalScale"] { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [1e39, -1e39, 1e12] { + let r = s.execute("type.setStyle", json!({"layer": id, key: value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key}={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "{key}: no history step"); + let r = s.execute("type.create", json!({"text": "x", key: 1e39})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "create {key}: {r:?}"); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_{key}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("{key}: save failed: {e:?}")); + } +} + +#[test] +fn in_range_style_numbers_still_apply_and_save() { + let (mut s, id) = fixture(); + s.execute("type.setStyle", json!({"layer": id, "leading": 30, "baselineShift": -4, "horizontalScale": 120, "verticalScale": 80})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "leading": "auto"})).unwrap(); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_ok_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("save failed: {e:?}")); +} + +#[test] +fn non_numeric_leading_is_refused() { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [json!("18pt"), json!("Auto "), json!(true), json!([12])] { + let r = s.execute("type.setStyle", json!({"layer": id, "leading": value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "leading={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "no history step"); +} diff --git a/crates/engine/tests/it/open_identity.rs b/crates/engine/tests/it/open_identity.rs new file mode 100644 index 0000000000..2fa4ae17f6 --- /dev/null +++ b/crates/engine/tests/it/open_identity.rs @@ -0,0 +1,51 @@ +//! Background imports report the admitted document's identity, not just its temporary tab index. + +use std::sync::Arc; + +use photocraft_engine::Session; +use photocraft_engine::jobs::{OpenSource, Started}; +use serde_json::{Value, json}; + +fn source() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session +} + +fn open(session: &mut Session, name: &str, bytes: Vec) -> Value { + match session.start_open(name, OpenSource::Bytes(Arc::new(bytes))).unwrap() { + Started::Done(result) => result, + Started::Job(job) => session.wait_job(job).unwrap(), + } +} + +#[test] +fn open_identity_reports_the_live_document_after_tabs_move() { + let input = source(); + let bytes = photocraft_io::export(&input.active().unwrap().doc, "synthetic.png", &Default::default()).unwrap().bytes; + let mut session = source(); + let result = open(&mut session, "synthetic.png", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let imported = session.documents()[index].doc.id; + assert_eq!(result["docId"], imported.0, "the engine result must carry the actual admitted identity: {result}"); + session.move_document(index, 0).unwrap(); + assert_eq!(session.documents()[0].doc.id.0, result["docId"]); + assert_ne!(session.documents()[index].doc.id, imported, "the old index now identifies another document"); +} + +#[test] +fn open_identity_reports_the_remapped_id_when_a_native_document_is_opened_twice() { + let mut session = source(); + let original = session.active().unwrap().doc.clone(); + // A wholly synthetic in-memory native file retains its DocId. PNG and DNG allocate fresh + // IDs during decode, so they cannot deterministically exercise this collision. + let bytes = photocraft_format::PcraftWriter::new().save_zip(&original, &Default::default()).unwrap().0; + let result = open(&mut session, "synthetic.pcraft", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let admitted = session.documents()[index].doc.id; + assert_ne!(admitted, original.id, "the original document still owns its persisted ID"); + assert_eq!(result["docId"], admitted.0, "report the remapped identity, not the ID decoded from the file"); + assert!(Arc::ptr_eq(&session.documents()[0].doc, &original)); +} diff --git a/crates/engine/tests/path_blur_speed_clamp.rs b/crates/engine/tests/it/path_blur_speed_clamp.rs similarity index 100% rename from crates/engine/tests/path_blur_speed_clamp.rs rename to crates/engine/tests/it/path_blur_speed_clamp.rs diff --git a/crates/engine/tests/path_transform_target.rs b/crates/engine/tests/it/path_transform_target.rs similarity index 100% rename from crates/engine/tests/path_transform_target.rs rename to crates/engine/tests/it/path_transform_target.rs diff --git a/crates/engine/tests/pdn.rs b/crates/engine/tests/it/pdn.rs similarity index 100% rename from crates/engine/tests/pdn.rs rename to crates/engine/tests/it/pdn.rs diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs similarity index 81% rename from crates/engine/tests/photoshop_oracles.rs rename to crates/engine/tests/it/photoshop_oracles.rs index 2edd13b2ba..88e63b67fc 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/it/photoshop_oracles.rs @@ -3,7 +3,7 @@ //! `cargo xtask corpus --all`. Feature `corpus` (run with `cargo xtask test-corpus`); a missing //! corpus fails. //! -//! The io corpus test (`crates/io/tests/corpus.rs`) flattens imported documents, which composite +//! The io corpus test (`crates/io/tests/it/corpus.rs`) flattens imported documents, which composite //! Photoshop's own cached pixels for smart objects and type layers. This test throws those caches //! away: every smart object is re-rendered from its embedded source through our smart-filter //! stack, and every type layer is re-laid-out and re-rasterised by our text engine; the flatten is @@ -80,6 +80,20 @@ fn rerender(doc: &mut Document) -> Result { Ok(n) } +/// Re-rendering Photoshop's Screen/Motion Blur oracle matches its merged composite, so the result retains the input's coverage. +#[test] +fn screen_motion_blur_retains_input_coverage() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-blend-screen-100.psd"); + let bytes = std::fs::read(&path).expect("Screen/Motion Blur oracle missing: run cargo xtask corpus --all"); + let file = PsdFile::from_bytes(&bytes).expect("parse Screen/Motion Blur oracle"); + let mut doc = photocraft_io::import("sf-blend-screen-100.psd", &bytes).expect("import Screen/Motion Blur oracle").document; + assert!(rerender(&mut doc).expect("re-render Screen/Motion Blur oracle") > 0); + let merged = photocraft_io::merged_composite(&file).expect("Photoshop merged composite"); + let (error, bad) = max_diff(&photocraft_compose::flatten(&doc).px, &merged); + assert!(error <= PASS_TOL, "Screen/Motion Blur error {error}, {bad}% pixels exceed 2/255"); +} + +/// Re-render every Photoshop oracle in the corpus with PhotoCraft's filters and compare it with Photoshop's own merged composite. #[test] fn photoshop_oracles_rerendered() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop"); diff --git a/crates/engine/tests/pixel_lock_edits.rs b/crates/engine/tests/it/pixel_lock_edits.rs similarity index 100% rename from crates/engine/tests/pixel_lock_edits.rs rename to crates/engine/tests/it/pixel_lock_edits.rs diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/it/precheck.rs similarity index 100% rename from crates/engine/tests/precheck.rs rename to crates/engine/tests/it/precheck.rs diff --git a/crates/engine/tests/prefs_usage.rs b/crates/engine/tests/it/prefs_usage.rs similarity index 100% rename from crates/engine/tests/prefs_usage.rs rename to crates/engine/tests/it/prefs_usage.rs diff --git a/crates/engine/tests/rasterize_psd_reopen.rs b/crates/engine/tests/it/rasterize_psd_reopen.rs similarity index 100% rename from crates/engine/tests/rasterize_psd_reopen.rs rename to crates/engine/tests/it/rasterize_psd_reopen.rs diff --git a/crates/engine/tests/rasterize_selected_layers.rs b/crates/engine/tests/it/rasterize_selected_layers.rs similarity index 100% rename from crates/engine/tests/rasterize_selected_layers.rs rename to crates/engine/tests/it/rasterize_selected_layers.rs diff --git a/crates/engine/tests/rendering_preferences.rs b/crates/engine/tests/it/rendering_preferences.rs similarity index 100% rename from crates/engine/tests/rendering_preferences.rs rename to crates/engine/tests/it/rendering_preferences.rs diff --git a/crates/engine/tests/save_keeps_encoding.rs b/crates/engine/tests/it/save_keeps_encoding.rs similarity index 100% rename from crates/engine/tests/save_keeps_encoding.rs rename to crates/engine/tests/it/save_keeps_encoding.rs diff --git a/crates/engine/tests/selection_layer_commands.rs b/crates/engine/tests/it/selection_layer_commands.rs similarity index 100% rename from crates/engine/tests/selection_layer_commands.rs rename to crates/engine/tests/it/selection_layer_commands.rs diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/it/shortcut_sets.rs similarity index 100% rename from crates/engine/tests/shortcut_sets.rs rename to crates/engine/tests/it/shortcut_sets.rs diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/it/shortcuts_headless.rs similarity index 100% rename from crates/engine/tests/shortcuts_headless.rs rename to crates/engine/tests/it/shortcuts_headless.rs diff --git a/crates/engine/tests/smart_clipboard_sources.rs b/crates/engine/tests/it/smart_clipboard_sources.rs similarity index 100% rename from crates/engine/tests/smart_clipboard_sources.rs rename to crates/engine/tests/it/smart_clipboard_sources.rs diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/it/smart_duplicates.rs similarity index 100% rename from crates/engine/tests/smart_duplicates.rs rename to crates/engine/tests/it/smart_duplicates.rs diff --git a/crates/engine/tests/smart_filter_canvas_geometry.rs b/crates/engine/tests/it/smart_filter_canvas_geometry.rs similarity index 100% rename from crates/engine/tests/smart_filter_canvas_geometry.rs rename to crates/engine/tests/it/smart_filter_canvas_geometry.rs diff --git a/crates/engine/tests/it/smart_object_tones.rs b/crates/engine/tests/it/smart_object_tones.rs new file mode 100644 index 0000000000..3c57526bad --- /dev/null +++ b/crates/engine/tests/it/smart_object_tones.rs @@ -0,0 +1,54 @@ +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_raster::Surface; +use serde_json::json; + +fn mean_difference(a: &Surface, b: &Surface) -> f64 { + let r = a.content_bounds().intersect(&b.content_bounds()); + let (mut sum, mut n) = (0f64, 0u32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (p, q) = (a.rgba(x, y), b.rgba(x, y)); + for c in 0..3 { + sum += f64::from((p[c] - q[c]).abs()); + n += 1; + } + } + } + sum / f64::from(n.max(1)) * 255.0 +} + +fn converted(cmd: &str) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [60, 40]], "size": 20, "hardness": 0.3, "color": "#e04a20"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 40], [60, 5]], "size": 14, "hardness": 0.6, "color": "#2a7fd0"})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute(cmd, json!({})).unwrap(); + (s, pixel, smart) +} + +#[test] +fn smart_objects_re_render_with_the_tones_of_the_converted_document() { + for cmd in ["image.mode.bits32", "image.mode.grayscale"] { + let (s, pixel, smart) = converted(cmd); + let st = s.active().unwrap(); + let layer = st.doc.layer(smart).unwrap().clone(); + let mut rerendered = layer.clone(); + smart_cmds::refresh_layer(&st.doc, &mut rerendered).unwrap(); + let (LayerContent::Smart(stored), LayerContent::Smart(fresh)) = (&layer.content, &rerendered.content) else { panic!("not a smart object") }; + let (stored, fresh) = (stored.cache.as_ref().unwrap(), fresh.cache.as_ref().unwrap()); + let reference = st.doc.layer(pixel).unwrap().surface().unwrap(); + let drift = mean_difference(stored, fresh); + assert!(drift < 1.0, "{cmd}: a re-render drifts {drift:.2}/255 from the converted rendering"); + let (off, baseline) = (mean_difference(fresh, reference), mean_difference(stored, reference)); + assert!( + off <= baseline + 0.5, + "{cmd}: the re-render is {off:.2}/255 from a pixel layer converted the same way, the converted rendering {baseline:.2}/255" + ); + } +} diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/it/smart_psd.rs similarity index 85% rename from crates/engine/tests/smart_psd.rs rename to crates/engine/tests/it/smart_psd.rs index 2586f24cc9..4aeda1519b 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/it/smart_psd.rs @@ -331,7 +331,7 @@ fn photoshop_samples() { } /// PSD export of a 24 MP smart object versus the same layer as pixels (release: -/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`). +/// `cargo test --release -p photocraft-engine --test it -- --ignored --nocapture smart_psd::perf`). #[test] #[ignore = "benchmark"] fn perf_24mp_smart_object_export() { @@ -469,6 +469,67 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } +/// A Perspective Warp smart filter as Photoshop writes it (`perspectiveWarpTransform`: vertices, +/// warped vertices and quads of vertex indices, document pixels) opens as +/// `layer.smartObjects.perspectiveWarp` and re-renders warped instead of flat (#6268), and keeps +/// its planes through PSD export, `.pcraft` and a PSD re-import. +#[test] +fn perspective_warp_psd_filter_renders_warped() { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; + let pnts = |p: &[[f64; 2]]| { + let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; + Value::List(p.iter().map(|q| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(q[0])).with("Vrtc", px(q[1])))).collect()) + }; + let quad = Descriptor::new("perspectiveWarpQuad").with("indices", Value::List((0..4).map(Value::Integer).collect())); + let fltr = Descriptor::new("perspectiveWarpTransform") + .with("vertices", pnts(&[[8.0, 6.0], [80.0, 6.0], [80.0, 58.0], [8.0, 58.0]])) + // The right edge moves in and shrinks: the content no longer reaches x = 70. + .with("warpedVertices", pnts(&[[8.0, 6.0], [50.0, 20.0], [50.0, 44.0], [8.0, 58.0]])) + .with("quads", Value::List(vec![Value::Descriptor(quad)])); + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Perspective Warp..."))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(708)); + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = photocraft_doc::LayerId(s.active().unwrap().active_layer.unwrap().0); + let filter = photocraft_io::smart_map::filter_from_item(&item); + assert_eq!(filter.command, "layer.smartObjects.perspectiveWarp"); + s.edit("perspective warp", |doc, _| { + let mut l = doc.layer(id).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") }; + sm.smart_filters.push(filter.clone()); + assert!(refresh_layer(doc, &mut l).unwrap()); + *doc.layer_mut(id).unwrap() = l; + Ok(()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let alpha = |doc: &Document, x: i32, y: i32| smart(doc).1.cache.unwrap().pixel(x, y)[3]; + assert!(alpha(&doc, 20, 30) > 0.99 && alpha(&doc, 70, 30) < 0.01 && alpha(&doc, 45, 10) < 0.01, "depth {depth}: warped"); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + keep(&format!("perspective-warp-{depth}.psd"), &out.bytes); + let back = photocraft_io::import("perspective.psd", &out.bytes).unwrap().document; + let native = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&back, &Default::default()).unwrap()).unwrap(); + for doc in [&back, &native] { + let (bid, after) = smart(doc); + assert_eq!(after.smart_filters, vec![filter.clone()], "depth {depth}"); + let mut l = doc.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(doc, &mut l).unwrap()); + let LayerContent::Smart(sm) = &l.content else { panic!("smart") }; + let (a, b) = (sm.cache.as_ref().unwrap(), smart(&s.active().unwrap().doc).1.cache.unwrap()); + assert_eq!(a.pixel(20, 30), b.pixel(20, 30), "depth {depth}: re-render"); + assert_eq!(a.pixel(70, 30), b.pixel(70, 30), "depth {depth}: re-render"); + } + } +} + /// A version-2 `liFD` item, as in Photoshop CS2's `lnkD` block: the fields every version has, and /// none of the version 5–7 fields after the file data. fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { @@ -670,3 +731,46 @@ fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() { let d = max_diff(&photocraft_compose::flatten(&back).px, &photocraft_compose::flatten(&doc).px); assert!(d <= 2.0 / 255.0, "re-opened differs by {d}"); } + +/// Image › Adjustments › Hue/Saturation on a smart object is a smart filter that Photoshop saves +/// as an `HStr` filter (#6267): it is written in that layout, opens as the same command and +/// params, and re-renders adjusted instead of passing the source through. +#[test] +fn hue_saturation_smart_filter_survives_psd_and_re_renders() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let plain = smart(&s.active().unwrap().doc).1.cache.unwrap(); + let params = json!({"hue": 40, "saturation": -30, "lightness": 10, "colorize": false, + "greens": {"hue": -20, "saturation": 15, "lightness": -5, "range": [75, 105, 135, 165]}}); + s.execute("image.adjustments.hueSaturation", params.clone()).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + assert_eq!(before.smart_filters.len(), 1); + assert_eq!(before.smart_filters[0].command, "image.adjustments.hueSaturation"); + let adjusted = before.cache.clone().unwrap(); + assert!(plain.pixel(20, 20) != adjusted.pixel(20, 20), "depth {depth}: the filter changes pixels"); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + keep(&format!("hue-sat-{depth}.psd"), &out.bytes); + let back = photocraft_io::import("hue-sat.psd", &out.bytes).unwrap().document; + let (bid, after) = smart(&back); + assert_eq!(after.smart_filters.len(), 1); + let f = &after.smart_filters[0]; + assert_eq!(f.command, "image.adjustments.hueSaturation", "depth {depth}: {}", f.params); + for (k, v) in params.as_object().unwrap() { + assert_eq!(&f.params[k], v, "depth {depth}: {k}"); + } + // Re-rendered from the source (as replaceContents / relink do), the filter still applies. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let LayerContent::Smart(sm) = &l.content else { panic!("smart") }; + let cache = sm.cache.as_ref().unwrap(); + for (x, y) in [(20, 20), (40, 30), (70, 50)] { + let (a, b) = (cache.pixel(x, y), adjusted.pixel(x, y)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() <= 1.0 / 255.0 + 1e-6), "depth {depth} at {x},{y}: {a:?} vs {b:?}"); + } + } +} diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/it/style_and_align.rs similarity index 100% rename from crates/engine/tests/style_and_align.rs rename to crates/engine/tests/it/style_and_align.rs diff --git a/crates/engine/tests/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs similarity index 100% rename from crates/engine/tests/svg_smart_object.rs rename to crates/engine/tests/it/svg_smart_object.rs diff --git a/crates/engine/tests/it/transform_budget_unclamped.rs b/crates/engine/tests/it/transform_budget_unclamped.rs new file mode 100644 index 0000000000..267667f33b --- /dev/null +++ b/crates/engine/tests/it/transform_budget_unclamped.rs @@ -0,0 +1,74 @@ +//! #6430: integration tests use the production budget (Memory Usage), not the unit tests' reduced +//! one. +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +fn painted() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + session +} + +/// `command` with `params` is refused as too large, and the document, its revision and History +/// are as they were. +fn assert_refused_without_changes(session: &mut Session, command: &str, params: Value) { + let state = session.active().unwrap(); + let (document, revision, history) = (state.doc.clone(), state.revision, state.history.past_len()); + let error = session.execute(command, params).unwrap_err().to_string(); + assert!(error.starts_with("too large to transform"), "{error}"); + let state = session.active().unwrap(); + assert!(state.doc == document, "refusal changed the document"); + assert_eq!((state.revision, state.history.past_len()), (revision, history)); +} + +#[test] +fn extreme_layer_scales_are_refused_before_a_clipped_strip_is_warped() { + let mut session = painted(); + session.execute("filter.render.clouds", json!({})).unwrap(); + for scale in [1e9, 1e30, 1e308, -1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "edit.transform", json!({(axis): scale})); + } + } +} + +/// Transform Selection refuses the same extreme scales and keeps the selection. +#[test] +fn extreme_selection_scales_are_refused_without_losing_the_selection() { + let mut session = painted(); + session.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + for scale in [1e30, 1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "select.transformSelection", json!({(axis): scale})); + } + } +} + +/// #7807: what one transform's result may take is Memory Usage (Preferences › Performance), so +/// raising it lets a refused transform through. +#[test] +fn memory_usage_is_what_a_transform_may_take() { + let mut session = painted(); + // The 1 x 8 px column stretched 40000 times about its centre: 40003 x 10 px of 8-bit RGBA, 1.6 MB. + let stretch = json!({"scaleX": 4e6, "interpolation": "nearest"}); + session.edit_prefs(|p| p.performance.memory_usage_mb = 1); + let state = session.active().unwrap(); + let (document, history) = (state.doc.clone(), state.history.past_len()); + let error = session.execute("edit.transform", stretch.clone()).unwrap_err().to_string(); + assert!(error.contains("would be 40003 x 10 px (2 MB), more than the 1 MB Memory Usage allows (Preferences › Performance)"), "{error}"); + let state = session.active().unwrap(); + assert!(state.doc == document && state.history.past_len() == history, "refusal changed the document"); + session.edit_prefs(|p| p.performance.memory_usage_mb = 2); + session.execute("edit.transform", stretch).unwrap(); + let state = session.active().unwrap(); + let bounds = state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!((bounds.width(), bounds.height()), (40_000, 8), "stretched, nothing clipped"); +} diff --git a/crates/engine/tests/type_format_targets.rs b/crates/engine/tests/it/type_format_targets.rs similarity index 100% rename from crates/engine/tests/type_format_targets.rs rename to crates/engine/tests/it/type_format_targets.rs diff --git a/crates/engine/tests/it/type_postscript_font.rs b/crates/engine/tests/it/type_postscript_font.rs new file mode 100644 index 0000000000..06171d7d84 --- /dev/null +++ b/crates/engine/tests/it/type_postscript_font.rs @@ -0,0 +1,94 @@ +//! `type.create` accepts an exact PostScript face name in its font field. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::Session; +use serde_json::json; + +fn font_with_postscript_name(source: &[u8], postscript: &str) -> Vec { + let mut bytes = source.to_vec(); + let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize; + let name_record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..r + 4] == b"name").unwrap(); + for i in 0..count { + let r = 12 + i * 16; + let offset = u32::from_be_bytes(bytes[r + 8..r + 12].try_into().unwrap()) as usize; + if &bytes[r..r + 4] == b"OS/2" { + bytes[offset + 4..offset + 6].copy_from_slice(&900u16.to_be_bytes()); + } + } + let names = [(1u16, "PhotoCraft Black Test"), (2, "Black"), (6, postscript), (16, "PhotoCraft Black Test"), (17, "Black")]; + let mut table = Vec::new(); + let mut strings = Vec::new(); + for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] { + table.extend(value.to_be_bytes()); + } + for (id, value) in names { + let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3u16, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + table.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + table.extend(strings); + let offset = bytes.len() as u32; + bytes[name_record + 8..name_record + 12].copy_from_slice(&offset.to_be_bytes()); + bytes[name_record + 12..name_record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes()); + bytes.extend(table); + bytes +} + +#[test] +fn type_create_resolves_exact_postscript_font_names() { + let ps = "PhotoCraft-Black-Test"; + photocraft_text::shared() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fonts + .register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps)); + + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let response = session.execute("type.create", json!({"text": "Test", "font": ps})).unwrap(); + assert!(response["warnings"].as_array().is_none_or(Vec::is_empty), "{response}"); + let id = LayerId(response["layer"].as_u64().unwrap()); + let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style = &text.char_runs()[0].style; + assert_eq!(style.font_family, "PhotoCraft Black Test"); + assert_eq!(style.postscript_name.as_deref(), Some(ps)); + assert_eq!(style.weight, 900); + + let regular = session.execute("type.create", json!({"text": "Test", "font": "Inter"})).unwrap(); + assert!(regular["warnings"].as_array().is_none_or(Vec::is_empty), "{regular}"); + let missing = session.execute("type.create", json!({"text": "Test", "font": "No Such Family 123"})).unwrap(); + assert_eq!(missing["warnings"], json!(["font 'No Such Family 123' is not installed; text may render using a fallback face"])); +} + +#[test] +fn exact_postscript_face_clears_retained_weight_variation() { + let ps = "PhotoCraft-Black-Test-Var"; + photocraft_text::shared() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fonts + .register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps)); + + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let response = session.execute("type.create", json!({"text": "Test", "font": "Inter", "variations": {"wght": 300}})).unwrap(); + let id = LayerId(response["layer"].as_u64().unwrap()); + + // Setting an exact PostScript face should clear the previously retained wght variation + session.execute("type.setStyle", json!({"layer": id.0, "font": ps})).unwrap(); + let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style = &text.char_runs()[0].style; + assert_eq!(style.font_family, "PhotoCraft Black Test"); + assert_eq!(style.weight, 900); + assert!(!style.variations.iter().any(|v| v.axis == "wght")); + + // Supplying a variations.wght in the same call should apply after clearing + session.execute("type.setStyle", json!({"layer": id.0, "font": ps, "variations": {"wght": 700}})).unwrap(); + let LayerContent::Text(text2) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style2 = &text2.char_runs()[0].style; + assert_eq!(style2.font_family, "PhotoCraft Black Test"); + assert_eq!(style2.weight, 900); + assert_eq!(style2.variations.iter().find(|v| v.axis == "wght").map(|v| v.value), Some(700.0)); +} diff --git a/crates/engine/tests/undo_keeps_active_layer.rs b/crates/engine/tests/it/undo_keeps_active_layer.rs similarity index 100% rename from crates/engine/tests/undo_keeps_active_layer.rs rename to crates/engine/tests/it/undo_keeps_active_layer.rs diff --git a/crates/engine/tests/unicode_colors.rs b/crates/engine/tests/it/unicode_colors.rs similarity index 100% rename from crates/engine/tests/unicode_colors.rs rename to crates/engine/tests/it/unicode_colors.rs diff --git a/crates/engine/tests/it/unsupported_smart_filters.rs b/crates/engine/tests/it/unsupported_smart_filters.rs new file mode 100644 index 0000000000..8861fea4d0 --- /dev/null +++ b/crates/engine/tests/it/unsupported_smart_filters.rs @@ -0,0 +1,85 @@ +use photocraft_doc::{LayerContent, LayerId, SmartFilter}; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +const STORED: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn mockup() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("imported filter", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + sm.smart_filters.push(SmartFilter { + command: "psd.unsupportedFilter".into(), + params: json!({"name": "Sharpen", "filterID": 1399353968, "psd": ""}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn cache(s: &Session, id: LayerId) -> photocraft_raster::Surface { + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a smart object") }; + sm.cache.clone().unwrap() +} + +fn png() -> Vec { + let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, vec![0; 8 * 8 * 3]).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +#[test] +fn geometry_edits_keep_the_stored_render_of_an_unsupported_filter() { + for (cmd, params) in [("edit.transform", json!({"scaleX": 50, "scaleY": 50})), ("image.imageSize", json!({"width": 20, "height": 15}))] { + let (mut s, id) = mockup(); + let before = cache(&s, id).content_bounds(); + s.execute(cmd, params).unwrap(); + let after = cache(&s, id); + let r = after.content_bounds(); + assert!(r.width() < before.width(), "{cmd} resized the stored render"); + assert_eq!(after.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "{cmd} kept the stored render"); + } +} + +#[test] +fn replacing_contents_reports_the_filters_it_cannot_apply() { + let (mut s, id) = mockup(); + let r = smart_cmds::replace_contents_bytes(&mut s, id, "new.png", png()).unwrap(); + let warnings = r["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Sharpen"), "{r}"); +} + +#[test] +fn supported_filters_still_re_render_after_a_transform() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("stale cache", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + let c = cache(&s, id); + let r = c.content_bounds(); + assert_ne!(c.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "a supported filter re-renders from the source"); +} diff --git a/crates/engine/tests/wand_transparent_copy.rs b/crates/engine/tests/it/wand_transparent_copy.rs similarity index 100% rename from crates/engine/tests/wand_transparent_copy.rs rename to crates/engine/tests/it/wand_transparent_copy.rs diff --git a/crates/engine/tests/working_rgb_profile.rs b/crates/engine/tests/it/working_rgb_profile.rs similarity index 100% rename from crates/engine/tests/working_rgb_profile.rs rename to crates/engine/tests/it/working_rgb_profile.rs diff --git a/crates/engine/tests/it/xmp_charref.rs b/crates/engine/tests/it/xmp_charref.rs new file mode 100644 index 0000000000..a4b98da4e5 --- /dev/null +++ b/crates/engine/tests/it/xmp_charref.rs @@ -0,0 +1,24 @@ +use photocraft_engine::file_cmds::read_file_info; + +#[test] +fn numeric_character_references_are_decoded() { + let xmp = "\ + Line one Line two © & co A &#38;\ + "; + let info = read_file_info(Some(xmp)); + assert_eq!(info["description"], "Line one\nLine two © & co A &"); +} + +/// Many `&` with no `;` near them stay literal, and are scanned a bounded distance each (a +/// multi-megabyte XMP once made this quadratic). +#[test] +fn a_long_run_of_ampersands_without_semicolons_is_left_unchanged() { + let run = "&".repeat(200_000); + let xmp = format!( + "\ + {run}\ + " + ); + let info = read_file_info(Some(&xmp)); + assert_eq!(info["description"], run.as_str()); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..4267746633 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,7 +16,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true, features = ["unbounded_depth"] } +serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. @@ -34,5 +34,16 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } [target.'cfg(target_vendor = "apple")'.dependencies] libc = "0.2" +# Replacing a file keeps its ACL (#7073). `fcopyfile(COPYFILE_ACL)` is an `unsafe fn` and +# `fclonefileat(CLONE_ACL)` is APFS-only; exacl is a safe wrapper over acl_get_file/acl_set_file. +[target.'cfg(target_os = "macos")'.dependencies] +exacl = "0.13" + +# `ReplaceFileW` is how Windows replaces a file without dropping its DACL, named streams +# (Zone.Identifier, the Mark of the Web) and creation time (#7094, #7096). winsafe's `kernel` +# feature is a dependency-free safe wrapper; the workspace forbids writing the FFI call here. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", features = ["kernel"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..635bf924a2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -2,8 +2,9 @@ //! //! [`atomic_write`] never truncates the destination. It writes a temporary file in the **same //! directory**, flushes it to stable storage (`sync_all`), renames it over the destination and, -//! on Unix, fsyncs the directory so the rename itself survives a power loss. If anything fails, -//! the destination keeps its previous bytes and the temporary file is removed. +//! flushes the directory so the rename itself survives a power loss. Failures before replacement +//! keep the destination's previous bytes. On Windows a failed publication flush reports that the +//! new file is in place but its durability could not be confirmed (#7858). //! //! - **Windows:** antivirus scanners and the search indexer briefly hold files open, which makes //! the replacing rename fail with `PermissionDenied`. The rename is retried with exponential @@ -13,6 +14,10 @@ //! if it does anyway, the save fails with a clear error. It never falls back to writing the //! destination in place. //! - **Unwritable folder:** the save fails with a clear error instead of writing in place. +//! - **Read-only file:** the save is refused before anything is written (#6600). A rename only +//! needs the folder to be writable, so without this check Linux and macOS would silently replace +//! a `chmod 444` file while Windows refuses; now every platform refuses, as Photoshop does, and +//! the user saves a copy with Save As. //! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters //! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync` //! works. [`sync_file`] falls back to the plain one there (#1336). @@ -21,8 +26,15 @@ //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" //! error. //! +//! Replacing a file also carries its metadata across — permission bits, extended attributes, +//! the "downloaded from the internet" mark and the access-control list — so that saving a +//! document does not quietly undo a security decision someone made about the file. That part is +//! [`meta`], and it is best effort: it never fails a save and never touches the bytes. +//! //! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step. +pub mod meta; + use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -30,6 +42,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests /// wrap it to inject failures. pub trait AtomicFs { + /// Whether the existing file at `path` is marked read-only (a missing file is not). A + /// read-only destination is never replaced (#6600). By default nothing is read-only. + fn is_readonly(&self, path: &Path) -> bool { + let _ = path; + false + } /// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]). fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>; /// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to @@ -42,11 +60,18 @@ pub trait AtomicFs { fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; /// Remove a (temporary) file. fn remove(&self, path: &Path) -> io::Result<()>; - /// Flush a directory entry change (Unix); a no-op elsewhere. + /// Flush a directory entry change on Unix and Windows; a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; - /// Copy the destination's permissions onto the temporary file, so replacing a file keeps its - /// mode. Best effort. - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the + /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Replace the **existing** file `to` with `from`. Everywhere but Windows this is + /// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and + /// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the + /// failure-injecting test file systems want. + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + self.rename(from, to) + } } /// The real file system. @@ -76,20 +101,40 @@ impl AtomicFs for RealFs { fn remove(&self, path: &Path) -> io::Result<()> { std::fs::remove_file(path) } + /// Flush directory publication using a directory handle on Unix and Windows. + /// Windows requests only `FILE_ADD_FILE`; opening or flushing the handle can fail the save. fn sync_dir(&self, dir: &Path) -> io::Result<()> { #[cfg(unix)] { sync_file(&std::fs::File::open(dir)?) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + // FILE_ADD_FILE is the directory equivalent of FILE_WRITE_DATA. Requesting only + // this right permits FlushFileBuffers without requiring GENERIC_WRITE's additional + // directory permissions. BACKUP_SEMANTICS opens a directory, not a volume: no + // administrative privilege or volume-wide flush is needed. + const FILE_ADD_FILE: u32 = 0x0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let directory = std::fs::OpenOptions::new().access_mode(FILE_ADD_FILE).custom_flags(FILE_FLAG_BACKUP_SEMANTICS).open(dir)?; + directory.sync_all() + } + #[cfg(not(any(unix, windows)))] { let _ = dir; Ok(()) } } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - let perms = std::fs::metadata(from)?.permissions(); - std::fs::set_permissions(to, perms) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::copy_metadata(from, to) + } + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::replace_existing(from, to) + } + fn is_readonly(&self, path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) } } @@ -143,8 +188,8 @@ impl RenameRetry { /// Atomically replace (or create) `path` with `bytes`. See the module docs. /// -/// Errors keep their [`io::ErrorKind`] and say what happened and that the original file is -/// unchanged. +/// Errors keep their [`io::ErrorKind`]. Before replacement they say the original is unchanged; +/// a Windows publication-flush error says the new file may not survive a power loss. pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with(&RealFs, RenameRetry::platform(), path, bytes) } @@ -162,13 +207,16 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt /// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { - let target = resolve_symlink(path); + let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?; let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), _ => PathBuf::from("."), }; std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + if fs.is_readonly(&target) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("cannot save {}: the file is read-only; {UNCHANGED}", path.display()))); + } // Create a fresh temporary file next to the target (same directory, so the rename never // crosses a device). The name is unique per process and call; retry a few on collisions. @@ -190,17 +238,26 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & } let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; - // Keep the existing file's mode (e.g. 0600) instead of the process default. - if target.exists() { - let _ = fs.copy_permissions(&target, &tmp); + // Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process + // default, its extended attributes, and its ACL. Best effort, and before the swap, so the + // file the user ends up with never passes through a more permissive state. + let replacing = target.exists(); + if replacing { + let _ = fs.copy_metadata(&target, &tmp); } - if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + // Replacing an existing file goes through `replace_existing`, which on Windows is the one + // call that can keep the destination's DACL and named streams; creating a new one is a + // plain rename, with nothing to preserve. + let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) }; + if let Err(e) = retry_rename(retry, swap) { let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } - // The new bytes are in place; a failed directory fsync can't be undone and must not report - // the save as failed, so it is best effort. + #[cfg(windows)] + fs.sync_dir(&dir).map_err(|e| publication_error(e, path))?; + // Keep the existing Unix policy here; #5420 separately changes its error handling. + #[cfg(not(windows))] let _ = fs.sync_dir(&dir); Ok(()) } @@ -229,12 +286,37 @@ fn sleep_ms(ms: u64) { #[cfg(target_arch = "wasm32")] fn sleep_ms(_ms: u64) {} +/// How deep a symlink chain we follow before giving up, like the OS limit. +const MAX_LINK_DEPTH: u32 = 40; + /// Write through a symlink to the file it points at, instead of replacing the link. -fn resolve_symlink(path: &Path) -> PathBuf { - match std::fs::symlink_metadata(path) { - Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), - _ => path.to_path_buf(), +/// +/// A dangling link (its target, or a folder on the way to it, doesn't exist yet) resolves to its +/// target too, so saving through one creates the target instead of silently turning the link +/// into a regular file (#6605). A chain that never ends is an error, never a link replacement. +fn resolve_symlink(path: &Path) -> io::Result { + if !std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(path.to_path_buf()); } + // A resolvable chain: canonicalize follows every link and relative target in one go. + if let Ok(real) = std::fs::canonicalize(path) { + return Ok(real); + } + // Dangling chain: walk the links lexically; the final target may not exist yet. + let mut current = path.to_path_buf(); + for _ in 0..MAX_LINK_DEPTH { + let Ok(link) = std::fs::read_link(¤t) else { + return Ok(current); // not (or no longer) a link: the chain ends here + }; + let base = current.parent().unwrap_or(Path::new(".")).to_path_buf(); + // `..` stays in the path for the OS to resolve: folding it by text is wrong when a folder + // on the way is itself a symlink. + current = if link.is_absolute() { link } else { base.join(link) }; + if !std::fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(current); + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) } /// A unique hidden temporary file name to write beside `file` before renaming over it. @@ -252,6 +334,15 @@ fn context(e: io::Error, msg: String) -> io::Error { const UNCHANGED: &str = "the original file was not changed"; +/// Replacement already happened: never claim that the original file is unchanged or undo it. +#[cfg(windows)] +pub(crate) fn publication_error(error: io::Error, path: &Path) -> io::Error { + io::Error::new( + error.kind(), + format!("wrote {}, but could not confirm the save is durable; it may not survive a power loss or crash: {error}", path.display()), + ) +} + fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error { let msg = match e.kind() { io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => { diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs new file mode 100644 index 0000000000..10008faa9f --- /dev/null +++ b/crates/format/src/atomic/meta.rs @@ -0,0 +1,280 @@ +//! What a replacement keeps from the file it replaces. +//! +//! Saving writes a new file and renames it over the old one, so the user gets a file with the +//! right bytes and none of the old file's history: everything the operating system and other +//! programs had attached to the file that was just unlinked is gone. Some of it is a security +//! decision — *this came from the internet*, *only these people may read it* — so a save that +//! drops it quietly weakens the user's protections (#6603, #7073, #7094, #7095, #7096). +//! +//! | | macOS | Linux | Windows | +//! |---|---|---|---| +//! | permission bits | [`copy_metadata`] | [`copy_metadata`] | [`copy_metadata`] (read-only flag) | +//! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | +//! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | +//! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | +//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! +//! Everything here except [`replace_existing`] is **best effort and never touches the file's +//! bytes**: a save that cannot carry a piece of metadata across is still a save, because a +//! document the user can open matters more than a Finder tag. [`replace_existing`] *is* the +//! replacement step, so it holds the atomicity guarantee instead: it never leaves the +//! destination missing, and it falls back to a plain rename rather than fail a save. +//! +//! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which +//! `listxattr` does not report, so copying the listed attributes misses them; they would need +//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. + +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::fd::BorrowedFd; + +/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a +/// megabyte of them is not something a save should spend memory on. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const MAX_XATTR_NAMES: usize = 1 << 20; + +/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*` +/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth +/// holding a copy of it in memory during a save. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const MAX_XATTR_VALUE: usize = 16 << 20; + +/// First read size for an attribute list or value; grown on `ERANGE`. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const XATTR_CHUNK: usize = 4 << 10; + +/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it +/// is about to replace (`from`) onto the temporary file that will replace it (`to`). +/// +/// Errors are the caller's to ignore: each piece is attempted on its own, and the first failure +/// is returned only so a caller that wants to log one can. +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + // The permission bits go on last. Writing an attribute needs the destination open for + // writing, and replacing a read-only (0444) file would copy that mode onto the temporary + // file and then be unable to touch it again. + #[cfg(unix)] + let attributes = copy_xattrs_at(from, to); + #[cfg(not(unix))] + let attributes = Ok(()); + + #[cfg(target_os = "macos")] + let acl = copy_acl(from, to); + #[cfg(not(target_os = "macos"))] + let acl = Ok(()); + + // A save over a 0600 file must not widen it to whatever the process default is. + let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); + + attributes.and(acl).and(permissions) +} + +/// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the +/// descriptors to [`copy_xattrs`]. +#[cfg(unix)] +fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> { + use std::os::fd::AsFd; + let old = std::fs::File::open(from)?; + // Writing an attribute needs write access to the file, not only to its directory. + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_xattrs(old.as_fd(), new.as_fd()) +} + +/// Copy every extended attribute of `from` onto `to`, best effort, through the two open files +/// rather than their paths. +/// +/// Descriptors, not paths, because the automation write path holds capability-checked handles +/// and must never reach back out to an ambient path to finish a write (#7095). The same reason +/// makes this the right shape for [`copy_metadata`] too. +/// +/// An attribute that cannot be read or cannot be set is skipped: `com.apple.provenance` and +/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to +/// be written by an ordinary process, and failing a document save over one would be absurd. +/// `Err` means the attribute *list* could not be read, so nothing was copied. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> { + use rustix::fs::{XattrFlags, fsetxattr}; + + for name in list_xattrs(from)?.split(|b| *b == 0) { + if name.is_empty() || skip_xattr(name) { + continue; + } + let Some(value) = read_xattr(from, name) else { continue }; + let _ = fsetxattr(to, name, &value, XattrFlags::empty()); + } + Ok(()) +} + +/// [`copy_xattrs`] where rustix offers no extended-attribute calls (FreeBSD and the other BSDs): +/// nothing is copied, as on Windows. +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))))] +pub fn copy_xattrs(_from: BorrowedFd<'_>, _to: BorrowedFd<'_>) -> io::Result<()> { + Ok(()) +} + +/// Attributes that describe how the *old* file's bytes are stored rather than anything about +/// the document, and so must never be pinned onto a file holding different bytes. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn skip_xattr(name: &[u8]) -> bool { + // `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself. + // The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming + // compressed storage it does not have would read as garbage, so never try. + name == b"com.apple.decmpfs" +} + +/// The NUL-separated attribute names of `fd`. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::flistxattr(fd, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Ok(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_NAMES => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_NAMES), 0); + } + Err(e) => return Err(e.into()), + } + } +} + +/// The value of one attribute, or `None` if it cannot be read or is larger than +/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute +/// between the listing and the read. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::fgetxattr(fd, name, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Some(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_VALUE => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_VALUE), 0); + } + Err(_) => return None, + } + } +} + +/// Copy an explicit macOS access-control list (`chmod +a`) from `from` onto `to` (#7073). +/// +/// `fcopyfile(COPYFILE_ACL)` is the obvious call and is an `unsafe fn`, which this workspace +/// forbids; `fclonefileat` with `CLONE_ACL` is safe but only works on APFS and would mean +/// writing every save as a clone-then-truncate. `exacl` is a small, safe wrapper over +/// `acl_get_file`/`acl_set_file` that works on every file system that has ACLs at all. +/// +/// An old file with no ACL leaves `to` alone: the new file may have picked up the folder's +/// inherited entries, and taking access away is a worse surprise than leaving it as the folder +/// says it should be. +#[cfg(target_os = "macos")] +fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { + let entries = exacl::getfacl(from, None)?; + if entries.is_empty() { + return Ok(()); + } + exacl::setfacl(&[to], &entries, None) +} + +/// Replace the **existing** file `to` with `from`, keeping what the destination owns rather than +/// its bytes. +/// +/// Everywhere but Windows this is exactly [`std::fs::rename`], and the metadata is carried by +/// [`copy_metadata`] onto `from` beforehand. On Windows it is `ReplaceFileW`, which preserves +/// the replaced file's DACL (#7096), its named streams — `Zone.Identifier`, the Mark of the Web +/// (#7094) — its creation time, object identifier, compression and encryption, in the one call +/// Microsoft documents for the save-through-a-temporary-file pattern. None of those have a safe +/// path-based API to copy by hand. +pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(windows)] + { + windows_replace(from, to) + } + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } +} + +/// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. +/// +/// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before +/// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no +/// longer exists and the replacement file exists under its original name" — the user's document +/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// leaves both files where they were. So this always passes one, and deletes it on the way out. +/// +/// The failures and what each leaves behind (from the `ReplaceFileW` reference): +/// +/// - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (1177) is the only one that moves anything: the +/// replacement keeps its name but has already inherited the destination's streams and +/// attributes, and the destination's old content is at the backup name. Finishing the rename +/// by hand completes the save *with* its metadata; if even that fails, the backup goes back to +/// the destination so the user's file is where they left it. +/// - Every other error leaves the replaced and replacement files under their own names, so a +/// plain rename still saves the document — without the metadata, which is where this started. +#[cfg(windows)] +fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { + use winsafe::co::REPLACEFILE; + + /// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the destination now lives under the backup name. + const MOVE_REPLACEMENT_2: i32 = 1177; + + let backup = backup_path(to); + let paths = (to.to_str(), from.to_str(), backup.as_deref().and_then(Path::to_str)); + // `ReplaceFileW` takes `&str`; a path that is not valid Unicode, or one past MAX_PATH, is + // the plain rename's job. + let (Some(replaced), Some(replacement), Some(backup_str)) = paths else { + return std::fs::rename(from, to); + }; + + let flags = REPLACEFILE::IGNORE_MERGE_ERRORS | REPLACEFILE::IGNORE_ACL_ERRORS; + match winsafe::ReplaceFile(replaced, replacement, Some(backup_str), flags) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(rename_failed) => { + if let Some(backup) = backup.as_deref() { + let _ = std::fs::rename(backup, to); + } + Err(rename_failed) + } + }, + Err(_) => { + remove_backup(backup.as_deref()); + std::fs::rename(from, to) + } + } +} + +/// A unique name beside `to` for `ReplaceFile` to park the replaced file under. It shares the +/// temporary-file suffix, so the save's own cleanup and the tests' leftover check cover it. +#[cfg(windows)] +fn backup_path(to: &Path) -> Option { + let name = to.file_name()?.to_string_lossy().into_owned(); + let dir = match to.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + Some(dir.join(super::temp_name(&name))) +} + +#[cfg(windows)] +fn remove_backup(backup: Option<&Path>) { + if let Some(backup) = backup { + let _ = std::fs::remove_file(backup); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs new file mode 100644 index 0000000000..834e87e66e --- /dev/null +++ b/crates/format/src/atomic/meta/tests.rs @@ -0,0 +1,310 @@ +//! What a save keeps from the file it replaces. +//! +//! The Unix tests run for real. The Windows ones exercise `ReplaceFileW` through +//! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` +//! and are covered by the Windows CI job, not by a developer on a Mac. + +use crate::atomic::atomic_write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let d = std::env::temp_dir().join(format!("pc-meta-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Self(d) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// No temporary or backup file is left beside the saved document. +fn assert_no_temp(dir: &Path) { + let leftovers: Vec<_> = + std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect(); + assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}"); +} + +// ---------------------------------------------------------------- extended attributes (Unix) + +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +mod xattrs { + use super::*; + + /// macOS wants a reverse-DNS name; Linux only lets an unprivileged process write `user.*`. + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + fn set(path: &Path, name: &str, value: &[u8]) -> bool { + rustix::fs::setxattr(path, name, value, rustix::fs::XattrFlags::empty()).is_ok() + } + + fn get(path: &Path, name: &str) -> Option> { + let mut buf = vec![0u8; 1 << 20]; + let len = rustix::fs::getxattr(path, name, buf.as_mut_slice()).ok()?; + buf.truncate(len); + Some(buf) + } + + /// #6603: the attributes of the replaced file — a Finder tag, a `user.*` attribute — are on + /// the file the user is left with. + #[test] + fn replacing_a_file_keeps_its_extended_attributes() { + let d = TempDir::new("xattr"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + // tmpfs and some CI file systems have no xattr support; nothing to assert. + return; + } + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "the replaced file's attribute was dropped"); + assert_no_temp(&d.0); + } + + /// A large value still comes across (and is not silently truncated). + #[test] + fn a_large_attribute_value_survives() { + let d = TempDir::new("xattr-big"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + // Comfortably past the first read buffer, so the grow-on-ERANGE path runs. + let value = vec![0xABu8; 64 << 10]; + if !set(&p, TAG, &value) { + return; + } + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(get(&p, TAG), Some(value)); + } + + /// A read-only file is refused outright (#6600), so a save never reaches the metadata copy and + /// the file keeps everything it had. The writable cases above cover the copy itself. + #[test] + fn a_read_only_file_keeps_its_attributes_and_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("xattr-ro"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + return; + } + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); + + // #6600: a read-only file is refused before anything is written, so the question this test + // asks — does a replace keep the attributes and the mode — is answered by the save not + // happening. The writable case above covers attributes surviving a replace. + let e = atomic_write(&p, b"second, longer").expect_err("a read-only file is refused"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); + + assert_eq!(std::fs::read(&p).unwrap(), b"first", "the original was left alone"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); + } + + /// Creating a file that was not there sets no attributes and does not fail. + #[test] + fn creating_a_new_file_needs_no_source() { + let d = TempDir::new("xattr-new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert!(get(&p, TAG).is_none()); + } + + /// The descriptor-based copy is what the automation write path uses (#7095): it works on two + /// open files and never looks at a path. + #[test] + fn copy_xattrs_works_on_descriptors() { + use std::os::fd::AsFd; + let d = TempDir::new("xattr-fd"); + let (from, to) = (d.join("from"), d.join("to")); + std::fs::write(&from, b"a").unwrap(); + std::fs::write(&to, b"b").unwrap(); + if !set(&from, TAG, b"carried") { + return; + } + + let old = std::fs::File::open(&from).unwrap(); + let new = std::fs::OpenOptions::new().write(true).open(&to).unwrap(); + super::super::copy_xattrs(old.as_fd(), new.as_fd()).unwrap(); + + assert_eq!(get(&to, TAG).as_deref(), Some(&b"carried"[..])); + assert_eq!(std::fs::read(&to).unwrap(), b"b", "copying attributes must not touch the bytes"); + } +} + +// --------------------------------------------------------------------------- mode (Unix) + +/// The mode was already kept before any of this; it still is. +#[cfg(unix)] +#[test] +fn replacing_a_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); + assert_no_temp(&d.0); +} + +// ----------------------------------------------------------------------------- ACL (macOS) + +#[cfg(target_os = "macos")] +mod acl { + use super::*; + + /// #7073: an ACL entry added with `chmod +a` is still there after a save. + #[test] + fn replacing_a_file_keeps_its_acl() { + let d = TempDir::new("acl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // The same thing `chmod +a` does: one explicit entry for the user running the test, so + // no second account has to exist. + let Ok(user) = std::env::var("USER") else { return }; + let entries = [exacl::AclEntry::allow_user(&user, exacl::Perm::READ | exacl::Perm::WRITE, None)]; + if exacl::setfacl(&[&p], &entries, None).is_err() { + // A file system without ACL support (a RAM disk, some CI volumes). + return; + } + let before = exacl::getfacl(&p, None).unwrap(); + assert!(!before.is_empty(), "the test could not set an ACL to begin with"); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(exacl::getfacl(&p, None).unwrap(), before, "the replaced file's ACL was dropped"); + assert_no_temp(&d.0); + } + + /// A file with no ACL keeps having no ACL, rather than gaining an empty one. + #[test] + fn a_file_without_an_acl_is_left_alone() { + let d = TempDir::new("acl-none"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = exacl::getfacl(&p, None).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(exacl::getfacl(&p, None).unwrap(), before); + } +} + +// --------------------------------------------------------------- named streams and ACL (Windows) + +/// Unverified on a Mac: these run only on Windows (CI's Windows job). +#[cfg(windows)] +mod windows { + use super::*; + + /// Open a named stream of `path`, e.g. `Zone.Identifier`. + fn stream(path: &Path, name: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(":"); + s.push(name); + PathBuf::from(s) + } + + /// #7094: a file marked as downloaded from the internet is still marked after a save, so the + /// shell and other applications keep warning about it. + #[test] + fn replacing_a_file_keeps_its_mark_of_the_web() { + let d = TempDir::new("motw"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let motw = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(stream(&p, "Zone.Identifier"), motw).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(stream(&p, "Zone.Identifier")).unwrap(), motw, "the Mark of the Web was dropped"); + assert_no_temp(&d.0); + } + + /// Any other named stream rides along too, because `ReplaceFileW` keeps the lot. + #[test] + fn replacing_a_file_keeps_other_named_streams() { + let d = TempDir::new("ads"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::write(stream(&p, "photocraft.test"), b"side data").unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::read(stream(&p, "photocraft.test")).unwrap(), b"side data"); + } + + /// #7096: an explicit ACL entry survives, instead of the file falling back to whatever the + /// folder hands down. Compared as the string `icacls` prints, which is what a user checks. + #[test] + fn replacing_a_file_keeps_its_explicit_acl() { + let d = TempDir::new("dacl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // Grant the current user an entry that inheritance would not have produced. + let user = std::env::var("USERNAME").unwrap_or_default(); + if user.is_empty() { + return; + } + let granted = std::process::Command::new("icacls").arg(&p).arg("/grant").arg(format!("{user}:(RX)")).output(); + if !granted.map(|o| o.status.success()).unwrap_or(false) { + return; + } + let before = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + + atomic_write(&p, b"second, longer").unwrap(); + + let after = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + assert_eq!(String::from_utf8_lossy(&after), String::from_utf8_lossy(&before), "the explicit ACL was dropped"); + assert_no_temp(&d.0); + } + + /// `ReplaceFileW` also keeps the creation time, which the rename used to reset (#6603). + #[test] + fn replacing_a_file_keeps_its_creation_time() { + let d = TempDir::new("born"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = std::fs::metadata(&p).unwrap().created().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(50)); + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), before); + } + + /// Creating a file where there was none still works: there is nothing to replace, so the + /// write never reaches `ReplaceFileW`. + #[test] + fn creating_a_new_file_still_works() { + let d = TempDir::new("new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert_no_temp(&d.0); + } +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..853a11293d 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -41,6 +41,9 @@ struct Faulty { /// Fail the first N renames with PermissionDenied (a transient lock), then succeed. locked_renames: Cell, renames: Cell, + /// Failure of the durability barrier after publication (#7858). + fail_sync_dir: bool, + synced: Cell, } impl AtomicFs for Faulty { @@ -66,16 +69,54 @@ impl AtomicFs for Faulty { fn remove(&self, path: &Path) -> io::Result<()> { RealFs.remove(path) } + /// Count publication barriers and optionally fail before the real directory flush. fn sync_dir(&self, dir: &Path) -> io::Result<()> { + self.synced.set(self.synced.get() + 1); + if self.fail_sync_dir { + return Err(io::Error::other("injected publication flush failure")); + } RealFs.sync_dir(dir) } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - RealFs.copy_permissions(from, to) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 }; +/// A successful rename does not establish power-loss durability. A failed Windows publication +/// barrier must leave the new bytes in place, report uncertainty, and allow a subsequent retry. +#[cfg(windows)] +#[test] +fn a_failed_publication_flush_is_reported_and_can_be_retried() { + let d = TempDir::new("publication-flush"); + let p = d.0.join("document.pcraft"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = Faulty { fail_sync_dir: true, ..Default::default() }; + + let error = atomic_write_with(&fs, NO_RETRY, &p, b"new document").unwrap_err(); + assert_eq!(fs.synced.get(), 1, "{error}"); + assert_eq!(error.kind(), io::ErrorKind::Other); + assert!(error.to_string().contains("document.pcraft"), "{error}"); + assert!(error.to_string().contains("may not survive"), "{error}"); + assert!(!error.to_string().contains(UNCHANGED), "the replacement already happened: {error}"); + assert_eq!(std::fs::read(&p).unwrap(), b"new document"); + assert_no_temp(&d.0); + + atomic_write(&p, b"retried document").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"retried document"); + assert_no_temp(&d.0); +} + +/// A real Windows directory handle must flush successfully and preserve directory-open errors. +#[cfg(windows)] +#[test] +fn windows_directory_flush_opens_a_directory_and_reports_open_errors() { + let d = TempDir::new("sync-directory"); + RealFs.sync_dir(&d.0).unwrap(); + assert_eq!(RealFs.sync_dir(&d.0.join("missing")).unwrap_err().kind(), io::ErrorKind::NotFound); +} + #[test] fn replaces_and_creates() { let d = TempDir::new("ok"); @@ -113,6 +154,7 @@ fn creates_missing_parent_folders() { assert_eq!(std::fs::read(&p).unwrap(), b"x"); } +/// A partial temporary write must preserve the original and never reach publication or its flush. #[test] fn write_failure_halfway_keeps_original() { let d = TempDir::new("write"); @@ -124,9 +166,11 @@ fn write_failure_halfway_keeps_original() { assert!(e.to_string().contains("not changed"), "{e}"); assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); assert_eq!(fs.renames.get(), 0, "never renames after a failed write"); + assert_eq!(fs.synced.get(), 0, "nothing was published"); assert_no_temp(&d.0); } +/// A failed replacement must preserve the original and skip the publication barrier. #[test] fn rename_failure_keeps_original() { let d = TempDir::new("rename"); @@ -134,6 +178,7 @@ fn rename_failure_keeps_original() { std::fs::write(&p, ORIGINAL).unwrap(); let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() }; assert!(atomic_write_with(&fs, NO_RETRY, &p, b"new").is_err()); + assert_eq!(fs.synced.get(), 0, "a failed replacement never reaches the publication barrier"); assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); assert_no_temp(&d.0); } @@ -176,6 +221,81 @@ fn lock_that_outlasts_the_retries_keeps_original() { assert_no_temp(&d.0); } +/// A file system that reports the target as read-only and records every write and rename. +#[derive(Default)] +struct ReadOnlyTarget { + writes: Cell, + renames: Cell, +} + +impl AtomicFs for ReadOnlyTarget { + fn is_readonly(&self, _path: &Path) -> bool { + true + } + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + self.writes.set(self.writes.get() + 1); + RealFs.write_new(tmp, bytes) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.renames.set(self.renames.get() + 1); + RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + RealFs.sync_dir(dir) + } + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) + } +} + +/// #6600: a read-only destination is refused before anything is written, with an error that says +/// so, instead of being replaced by the rename (which only needs the folder to be writable). +#[test] +fn read_only_target_is_refused_before_anything_is_written() { + let d = TempDir::new("rofake"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = ReadOnlyTarget::default(); + let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("a.psd") && e.to_string().contains("read-only") && e.to_string().contains("not changed"), "{e}"); + assert_eq!((fs.writes.get(), fs.renames.get()), (0, 0), "nothing is written or renamed"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// #6600 on the real file system: a file marked read-only (`chmod 444`, or the Windows read-only +/// attribute) keeps its bytes and the save says why; a writable one still saves. +#[test] +fn read_only_file_on_disk_is_never_replaced() { + let d = TempDir::new("rofile"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&p, perms.clone()).unwrap(); + let r = atomic_write(&p, b"new"); + let unchanged = std::fs::read(&p).unwrap(); + let still_readonly = std::fs::metadata(&p).unwrap().permissions().readonly(); + // Make it writable again so the folder can be removed (Windows refuses to delete it otherwise). + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&p, perms).unwrap(); + let e = r.unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("the file is read-only;"), "a clear reason, not a failed rename: {e}"); + assert_eq!(unchanged, ORIGINAL); + assert!(still_readonly); + assert_no_temp(&d.0); + // New files and writable files are unaffected. + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + atomic_write(&d.0.join("fresh.psd"), b"x").unwrap(); +} + #[cfg(unix)] #[test] fn read_only_folder_errors_clearly_and_never_writes_in_place() { @@ -221,6 +341,52 @@ fn writes_through_a_symlink() { assert_eq!(std::fs::read(&real).unwrap(), b"new"); } +/// #6605: saving onto a dangling symlink writes through to its target instead of replacing the +/// link with a regular file, even when a folder on the way to the target doesn't exist yet. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_symlink() { + let d = TempDir::new("dangling"); + let real = d.0.join("missing/dir/a.psd"); + let link = d.0.join("link.psd"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(&real).unwrap(), b"new", "the bytes land at the target"); + assert_no_temp(real.parent().unwrap_or(Path::new("."))); +} + +/// A dangling link with a relative target resolves against the link's own folder. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_relative_symlink() { + let d = TempDir::new("dangling-rel"); + let here = d.0.join("here"); + std::fs::create_dir(&here).unwrap(); + let link = here.join("link.psd"); + std::os::unix::fs::symlink("../elsewhere/real.psd", &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(d.0.join("elsewhere/real.psd")).unwrap(), b"new"); + assert_no_temp(&d.0.join("elsewhere")); +} + +/// A symlink loop is an error; it never replaces either link. +#[cfg(unix)] +#[test] +fn symlink_loop_errors_without_replacing_the_links() { + let d = TempDir::new("loop"); + let a = d.0.join("a.psd"); + let b = d.0.join("b.psd"); + std::os::unix::fs::symlink(&b, &a).unwrap(); + std::os::unix::fs::symlink(&a, &b).unwrap(); + let e = atomic_write(&a, b"new").unwrap_err(); + assert!(e.to_string().contains("symbolic links"), "{e}"); + assert!(std::fs::symlink_metadata(&a).unwrap().file_type().is_symlink()); + assert!(std::fs::symlink_metadata(&b).unwrap().file_type().is_symlink()); + assert_no_temp(&d.0); +} + #[test] fn directory_target_or_bare_root_is_an_error_not_a_panic() { let d = TempDir::new("dir"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 06150e60ed..fa3c9a4085 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -297,6 +297,24 @@ impl RecoveryStore { self.savers.iter().flat_map(|(&id, saver)| saver.take_completed().into_iter().map(move |(revision, result)| (id, revision, result))).collect() } + /// Finish every queued autosave and wait for it, then return the outcomes as + /// [`take_completed`](Self::take_completed) does. For a session that ends without asking, + /// e.g. because the window's graphics device was lost (#3438). The workers stop; a later + /// autosave of the same document starts a new one on the same entry, so a failed write can be + /// tried again. + pub fn flush(&mut self) -> Vec<(u64, u64, std::result::Result<(), String>)> { + for saver in self.savers.values_mut() { + saver.shutdown(); + } + let done = self.take_completed(); + // Keep each entry's key, so a later autosave writes the same entry and a discard (the + // document was saved or closed meanwhile) still removes it. + for (id, saver) in self.savers.drain() { + self.adopted.entry(id).or_insert(saver.key.clone()); + } + done + } + /// Document `doc_id` was saved or closed: remove its recovery data, both its own autosaves /// and the entry it adopted. pub fn discard(&mut self, doc_id: u64) -> Result<()> { diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 95d45fafb9..fda505dcff 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -152,6 +152,8 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { path: s.path.clone(), stroke: s.stroke.clone(), live: s.live.clone(), + density: s.density, + feather: s.feather, }, LayerContent::Smart(s) => ContentM::Smart { contents_id: Some(s.contents_id.0), @@ -209,6 +211,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { blend_if: l.blend_if.clone(), advanced: l.advanced, video: l.video.as_ref().map(|v| video_m(v, sink)), + xmp: l.xmp.as_deref().map(str::to_string), } } @@ -466,13 +469,15 @@ impl Loader<'_> { warp: warp.clone(), }) } - ContentM::Shape { fill, cache, psd_raw, path, stroke, live } => LayerContent::Shape(ShapeLayer { + ContentM::Shape { fill, cache, psd_raw, path, stroke, live, density, feather } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), live: live.clone(), cache: self.opt_surface(cache)?, psd_raw: self.opt_blob(psd_raw)?, + density: *density, + feather: *feather, }), ContentM::Smart { contents_id, source, transform, smart_filters, cache, psd_raw, filters_enabled, filter_mask, warp, stack_mode, perspective } => { LayerContent::Smart(SmartObject { @@ -543,6 +548,7 @@ impl Loader<'_> { blend_if: m.blend_if.clone(), advanced: m.advanced, video: m.video.as_ref().map(|v| self.video(v)).transpose()?, + xmp: m.xmp.as_deref().map(Arc::from), }) } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 0c87720212..992e925ae9 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -8,6 +8,7 @@ //! blobs/.zst content-addressed binary data (ICC, EXIF, PSD blocks, smart objects) //! thumb.png optional thumbnail (caller-rendered) //! composite/preview.png optional flattened preview (caller-rendered) +//! save.lock directory bundles only: held while a save runs (empty) //! ``` //! //! A bundle is either a ZIP archive (STORE entries) or a directory with the @@ -15,7 +16,9 @@ //! already compressed/wrote, so only new tiles are encoded. Directory saves //! verify existing content-addressed objects once per writer and folder, //! rechecking files whose size or modification time changes; they replace -//! damaged or missing objects and garbage-collect unreferenced ones. +//! damaged or missing objects and garbage-collect unreferenced ones. A directory +//! save holds an OS file lock on `save.lock` throughout, so two writers (or two +//! processes) saving one bundle take turns instead of removing each other's objects. //! //! This crate sits at L3 next to the compositor, so it does not render. //! Callers pass previews in [`SaveOptions`]; `photocraft-io` does that. diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 82480e05c0..72192c79f6 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -242,6 +242,9 @@ pub struct LayerM { pub advanced: photocraft_doc::AdvancedBlending, #[serde(default)] pub video: Option, + /// The layer's own XMP packet (missing in older files = none). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xmp: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -294,6 +297,12 @@ pub enum ContentM { stroke: Option, #[serde(default)] live: Option, + /// The path's density as the fill's mask; older files have none (100 %). + #[serde(default = "full_density")] + density: f32, + /// The path's feather in px; older files have none. + #[serde(default)] + feather: f32, }, Smart { /// Shared source identity; older bundles did not record it. @@ -318,6 +327,10 @@ pub enum ContentM { }, } +fn full_density() -> f32 { + 1.0 +} + fn yes() -> bool { true } @@ -385,3 +398,16 @@ mod channel_tests { assert_eq!(c.indicates, photocraft_doc::ColorIndicates::MaskedAreas); } } + +#[cfg(test)] +mod shape_tests { + use super::*; + + /// Shapes saved before Density and Feather load at 100 % and no feather. + #[test] + fn older_shapes_load_at_full_density_without_feather() { + let old = serde_json::json!({ "kind": "shape", "fill": null, "cache": null, "psd_raw": null }); + let ContentM::Shape { density, feather, .. } = serde_json::from_value(old).unwrap() else { panic!("a shape") }; + assert_eq!((density, feather), (1.0, 0.0)); + } +} diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..03a85cbc83 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -18,6 +18,10 @@ use crate::{FormatError, LoadOptions, Result, SaveOptions}; pub(crate) const MANIFEST: &str = "manifest.json"; pub(crate) const THUMB: &str = "thumb.png"; pub(crate) const COMPOSITE: &str = "composite/preview.png"; +/// The file a directory save holds an exclusive lock on for its whole transaction (#4957). +pub(crate) const SAVE_LOCK: &str = "save.lock"; +/// How long a directory save waits for another save of the same bundle to finish. +const SAVE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10); fn tile_path(h: &str) -> String { format!("tiles/{h}.zst") @@ -163,6 +167,10 @@ pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result< let layers = take_array(&mut v, "/document/layers"); let mut m: Manifest = serde_json::from_value(v)?; m.document.layers = layers_from_json(layers)?; + // A number past f32's range decodes as infinity, which no save could write back (#6862). + if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { + return Err(FormatError::corrupt(format!("`{path}` is out of range"))); + } Ok(m) } @@ -300,19 +308,24 @@ pub struct SaveStats { pub manifest_bytes: usize, } +type BlobHashCache = HashMap>, Hash)>; +type TileHashCache = HashMap<(usize, SampleType), (Weak, Hash)>; + #[derive(Default)] struct Collect<'c> { tiles: BTreeMap, SampleType)>, blobs: BTreeMap>>, - hash_cache: Option<&'c mut HashMap, Hash)>>, + hash_cache: Option<&'c mut TileHashCache>, + blob_hash_cache: Option<&'c mut BlobHashCache>, } impl Sink for Collect<'_> { + /// Reuses hashes by allocation and sample byte order without upgrading weak references. fn tile(&mut self, format: PixelFormat, tile: &Arc) -> Hash { - let key = Arc::as_ptr(tile) as usize; + let key = (Arc::as_ptr(tile) as usize, format.sample); if let Some(cache) = self.hash_cache.as_deref_mut() && let Some((w, h)) = cache.get(&key) - && w.upgrade().is_some_and(|t| Arc::ptr_eq(&t, tile)) + && std::ptr::eq(w.as_ptr(), Arc::as_ptr(tile)) { let h = h.clone(); self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample)); @@ -326,8 +339,21 @@ impl Sink for Collect<'_> { h } + /// Reuses hashes for the same allocation without upgrading weak references. fn blob(&mut self, data: &Arc>) -> Hash { + let key = Arc::as_ptr(data) as usize; + if let Some(cache) = self.blob_hash_cache.as_deref_mut() + && let Some((w, h)) = cache.get(&key) + && std::ptr::eq(w.as_ptr(), Arc::as_ptr(data)) + { + let h = h.clone(); + self.blobs.entry(h.clone()).or_insert_with(|| data.clone()); + return h; + } let h = hash_bytes(data); + if let Some(cache) = self.blob_hash_cache.as_deref_mut() { + cache.insert(key, (Arc::downgrade(data), h.clone())); + } self.blobs.entry(h.clone()).or_insert_with(|| data.clone()); h } @@ -361,16 +387,56 @@ pub const DEFAULT_REVERIFY_BUDGET: u64 = 16 * 1024 * 1024; /// [`Self::set_reverify_budget`]). Over enough saves every object is read again, and a damaged /// one is rewritten. pub struct PcraftWriter { - hash_cache: HashMap, Hash)>, + hash_cache: TileHashCache, + blob_hash_cache: BlobHashCache, /// Compressed objects by bundle path (ZIP mode). compressed: HashMap>>, + compressed_budget: usize, verified_directory: Option, reverify_budget: u64, + /// How long [`Self::save_dir`] waits for another save of the bundle ([`SAVE_LOCK_WAIT`]). + lock_wait: std::time::Duration, } impl Default for PcraftWriter { fn default() -> Self { - Self { hash_cache: HashMap::new(), compressed: HashMap::new(), verified_directory: None, reverify_budget: DEFAULT_REVERIFY_BUDGET } + Self { + hash_cache: HashMap::new(), + blob_hash_cache: HashMap::new(), + compressed: HashMap::new(), + compressed_budget: usize::MAX, + verified_directory: None, + reverify_budget: DEFAULT_REVERIFY_BUDGET, + lock_wait: SAVE_LOCK_WAIT, + } + } +} + +/// Hold `dir`'s [`SAVE_LOCK`] for a directory save, so two writers (two stores, or two +/// processes) can't interleave: each lists the existing objects, writes its own, publishes its +/// manifest and removes what that manifest doesn't reference. Interleaved, one writer's cleanup +/// could remove an object the other's winning manifest needs (#4957). The lock is the OS's +/// advisory file lock, released when the returned file is dropped or the process ends, so a +/// crash leaves no stale lock. Another save holding it is waited for up to `wait`; after that the +/// save fails without writing anything. A file system without file locks (some network shares) +/// saves unlocked, as before. +fn lock_bundle(dir: &Path, wait: std::time::Duration) -> Result> { + let path = dir.join(SAVE_LOCK); + validate_bundle_file(dir, &path)?; + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path)?; + let start = std::time::Instant::now(); + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(file)), + Err(std::fs::TryLockError::WouldBlock) if start.elapsed() < wait => std::thread::sleep(std::time::Duration::from_millis(20)), + Err(std::fs::TryLockError::WouldBlock) => { + return Err(FormatError::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("another save of {} is in progress; nothing was written", dir.display()), + ))); + } + Err(std::fs::TryLockError::Error(_)) => return Ok(None), + } } } @@ -521,7 +587,11 @@ fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { plain_file_or_missing(path) } -fn write_bundle_file(root: &Path, path: &Path, data: &[u8]) -> Result<()> { +/// Publish a validated bundle entry through a flushed temporary file and replacing rename. +/// On Windows, propagate a failed directory barrier even though the new entry is already visible. +fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atomic::AtomicFs) -> Result<()> { + #[cfg(not(windows))] + let _ = fs; validate_bundle_file(root, path)?; let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; let name = path.file_name().ok_or_else(|| unsafe_bundle_entry(path))?; @@ -558,6 +628,10 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8]) -> Result<()> { // itself, never its referent, even if one appears after this check. validate_bundle_file(root, path)?; crate::atomic::retry_rename(crate::atomic::RenameRetry::platform(), || std::fs::rename(&tmp, path))?; + #[cfg(windows)] + { + fs.sync_dir(parent).map_err(|e| crate::atomic::publication_error(e, path))?; + } #[cfg(unix)] { let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); @@ -725,10 +799,24 @@ impl PcraftWriter { self.reverify_budget = bytes; } + /// Cap compressed payload capacity retained between ZIP saves. Clears the previous payload + /// cache; tile hashes remain reusable. The default is unbounded; `0` retains no payloads. + pub fn set_compressed_cache_budget(&mut self, bytes: usize) { + self.compressed_budget = bytes; + self.compressed.clear(); + } + + /// How long a directory save waits for another save of the same bundle to finish before + /// failing without writing anything (10 s by default; see [`Self::save_dir`]). + pub fn set_lock_wait(&mut self, wait: std::time::Duration) { + self.lock_wait = wait; + } + fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); + self.blob_hash_cache.retain(|_, (w, _)| w.strong_count() > 0); convert::check_nesting(&doc.layers)?; - let mut c = Collect { hash_cache: Some(&mut self.hash_cache), ..Default::default() }; + 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 { @@ -773,27 +861,31 @@ impl PcraftWriter { 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(); for (path, obj) in &p.objects { - let data = match self.compressed.get(path) { - Some(d) => { + let (key, data) = match self.compressed.remove_entry(path) { + Some((key, data)) => { if matches!(obj, Object::Tile(..)) { stats.tiles_reused += 1; } - d.clone() + (Some(key), data) } None => { match obj { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed())) + (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed()))) } }; z.add(path, &data)?; - next.insert(path.clone(), data); + if data.capacity() <= self.compressed_budget.saturating_sub(cached_bytes) { + cached_bytes += data.capacity(); + next.insert(key.unwrap_or_else(|| path.clone()), data); + } } self.compressed = next; Ok((z.finish()?, stats)) @@ -801,10 +893,18 @@ impl PcraftWriter { /// Save into a directory bundle: reuses only valid existing objects, /// rewrites missing or damaged objects, then writes the manifest - /// atomically and removes unreferenced objects. + /// atomically and removes unreferenced objects. The whole save holds the bundle's + /// `save.lock`, so saves of one bundle from other writers or processes wait their turn. pub fn save_dir(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions) -> Result { + self.save_dir_with(doc, dir, opts, &crate::atomic::RealFs) + } + + /// The same save path with an injectable publication barrier, for failure/GC ordering tests. + fn save_dir_with(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions, fs: &dyn crate::atomic::AtomicFs) -> Result { let canonical_dir = bundle_root(dir)?; let dir = canonical_dir.as_path(); + // Held until the end of the save: listing, writing, publishing and cleanup (#4957). + let _lock = lock_bundle(dir, self.lock_wait)?; let existing = list_objects(dir)?; // Reject a hostile existing entry before writing any object or manifest. This also // covers entries that this save would otherwise leave untouched or garbage-collect. @@ -858,7 +958,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed())?; + write_bundle_file(dir, &object_path, &obj.compressed(), fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -872,22 +972,29 @@ impl PcraftWriter { } } for (name, data) in &p.previews { - write_bundle_file(dir, &dir.join(name), data)?; + write_bundle_file(dir, &dir.join(name), data, fs)?; } // Pin all GC directories before removing stale previews or objects. // Unix cleanup then stays within these directories even if a pathname is swapped. let gc = BundleGc::new(dir)?; + #[cfg(windows)] + { + // A previous attempt may have published an object and then failed its directory + // flush. The retry can reuse those valid bytes (including cached ones), so flush + // both object directories again before publishing a manifest that references them. + // This also persists the fixed subdirectories of a newly created bundle. + for sub in ["tiles", "blobs"] { + fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?; + } + } + write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?; + // 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) { - let path = dir.join(stale); - validate_bundle_file(dir, &path)?; - match remove_bundle_file(dir, &path, &gc) { - Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {} - other => other?, - } + let _ = remove_bundle_file(dir, &dir.join(stale), &gc); } } - write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; // The manifest is committed: the save has succeeded. Removing objects it no longer // references is best-effort housekeeping (a vanished or locked file must not turn a // complete save into a reported failure); a leftover is retried on the next save. @@ -941,6 +1048,77 @@ impl PcraftWriter { } } +#[cfg(test)] +#[test] +fn tile_hash_cache_keeps_sample_byte_order_in_its_identity() { + let mut cache = HashMap::new(); + let mut surface = photocraft_raster::Surface::new(PixelFormat::RGBA8); + surface.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]); + let tile = surface.tile(photocraft_geom::TileCoord::new(0, 0)).unwrap(); + let mut collect = Collect { hash_cache: Some(&mut cache), ..Default::default() }; + for sample in [SampleType::U8, SampleType::U16] { + let format = if sample == SampleType::U8 { PixelFormat::RGBA8 } else { PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true) }; + assert_eq!(collect.tile(format, tile), hash_bytes(&tile_le(tile, sample))); + } + drop(collect); + assert_eq!(cache.len(), 2); +} + +#[cfg(test)] +#[test] +fn blob_hash_cache_tracks_arc_identity_without_retaining_payloads() { + let mut cache = HashMap::new(); + let original = Arc::new(vec![1, 2, 3]); + let weak = Arc::downgrade(&original); + let mut collect = Collect { blob_hash_cache: Some(&mut cache), ..Default::default() }; + let hash = collect.blob(&original); + assert_eq!(collect.blob(&original), hash); + let mut edited = original.clone(); + Arc::make_mut(&mut edited)[0] = 7; + assert_eq!(collect.blob(&edited), hash_bytes(&[7, 2, 3])); + assert_ne!(collect.blob(&edited), hash); + drop(collect); + assert_eq!(cache.len(), 2); + drop(original); + assert!(weak.upgrade().is_none()); +} + +#[cfg(test)] +#[test] +fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() { + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + let mut doc = Document::new("cache", Size::new(257, 1), photocraft_color::ColorMode::Rgb, SampleType::U8); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + surface.write_pixel(256, 0, &[0.6, 0.4, 0.2, 1.0]); + doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface))); + let mut writer = PcraftWriter::new(); + let (expected, _) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + let key = writer.compressed.keys().next().unwrap().clone(); + let key_allocation = writer.compressed.get_key_value(&key).unwrap().0.as_ptr(); + assert_eq!(writer.save_zip(&doc, &SaveOptions::default()).unwrap().0, expected); + assert_eq!(writer.compressed.get_key_value(&key).unwrap().0.as_ptr(), key_allocation); + let budget = writer.compressed.values().map(|v| v.capacity()).min().unwrap(); + writer.set_compressed_cache_budget(budget); + assert!(writer.compressed.is_empty()); + let (bytes, _) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert!(writer.compressed.values().map(|v| v.capacity()).sum::() <= budget); + assert!(!writer.compressed.is_empty()); + let retained = writer.compressed.len(); + let (bytes, stats) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert_eq!(stats.tiles_reused, retained); + writer.set_compressed_cache_budget(0); + let (bytes, stats) = writer.save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(bytes, expected); + assert_eq!(stats.tiles_reused, 0); + assert!(writer.compressed.is_empty()); +} + #[cfg(all(test, unix))] #[test] fn gc_pinned_directory_handles_resist_subdirectory_swap() { @@ -1007,3 +1185,6 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { crate::atomic::atomic_write(path, data)?; Ok(()) } + +#[cfg(all(test, windows))] +mod durability_tests; diff --git a/crates/format/src/store/durability_tests.rs b/crates/format/src/store/durability_tests.rs new file mode 100644 index 0000000000..5f60db2948 --- /dev/null +++ b/crates/format/src/store/durability_tests.rs @@ -0,0 +1,113 @@ +//! Exercise the real directory save, including cached-object reuse and garbage collection. +use super::*; +use crate::atomic::{AtomicFs, RealFs}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Layer, Size}; +use std::cell::{Cell, RefCell}; +use std::io; + +/// The object or manifest directory barrier at which to inject one publication failure. +#[derive(Clone, Copy, Debug)] +enum FailAt { + Tiles, + Blobs, + Manifest, +} + +/// Real filesystem operations with recorded barriers and a single failure at the chosen stage. +struct PublicationFault { + root: PathBuf, + old_manifest: Vec, + stage: FailAt, + armed: Cell, + synced: RefCell>, +} + +impl AtomicFs for PublicationFault { + /// Keep temporary-file writes and file flushes real; only directory barriers are injected. + fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> { + RealFs.write_new(path, bytes) + } + /// Publish through the real rename so a barrier error occurs after replacement. + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.rename(from, to) + } + /// Use real cleanup so the test can detect premature removal of old objects. + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + /// Preserve normal replacement metadata behavior during failure injection. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) + } + /// Record every barrier and fail the selected stage once, allowing a same-writer retry. + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + self.synced.borrow_mut().push(dir.to_path_buf()); + let fail = match self.stage { + FailAt::Tiles => dir == self.root.join("tiles"), + FailAt::Blobs => dir == self.root.join("blobs"), + FailAt::Manifest => dir == self.root && std::fs::read(dir.join(MANIFEST))? != self.old_manifest, + }; + if fail && self.armed.replace(false) { + return Err(io::Error::other("injected publication flush failure")); + } + RealFs.sync_dir(dir) + } +} + +/// Failed object or manifest publication retains old objects; a retry flushes reused objects +/// before the manifest and resumes garbage collection only after durable publication. +#[test] +fn failed_bundle_publication_retains_old_objects_and_same_writer_retry_is_durable() { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for stage in [FailAt::Tiles, FailAt::Blobs, FailAt::Manifest] { + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pcraft-durability-{}-{id}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let dir = std::fs::canonicalize(dir).unwrap(); + let mut old = Document::new("old", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("pixels", old.pixel_format()); + layer.surface_mut().unwrap().write_pixel(0, 0, &[1.0, 0.0, 0.0, 1.0]); + old.layers.push(layer); + old.metadata.exif = Some(Arc::new(vec![1; 100])); + let mut new = old.clone(); + new.name = "new".into(); + new.layers[0].surface_mut().unwrap().write_pixel(0, 0, &[0.0, 0.0, 1.0, 1.0]); + new.metadata.exif = Some(Arc::new(vec![2; 100])); + + let mut writer = PcraftWriter::new(); + writer.save_dir(&old, &dir, &SaveOptions::default()).unwrap(); + let old_objects: Vec<_> = list_objects(&dir).unwrap().into_iter().map(|p| (p.clone(), std::fs::read(dir.join(p)).unwrap())).collect(); + assert!(old_objects.len() >= 2); + let fs = PublicationFault { + old_manifest: std::fs::read(dir.join(MANIFEST)).unwrap(), + root: dir.clone(), + stage, + armed: Cell::new(true), + synced: RefCell::new(Vec::new()), + }; + + let error = writer.save_dir_with(&new, &dir, &SaveOptions::default(), &fs).unwrap_err(); + assert!(!fs.armed.get(), "the {stage:?} failure fired"); + assert!(error.to_string().contains("may not survive"), "{error}"); + let expected = if matches!(stage, FailAt::Manifest) { &new } else { &old }; + assert_eq!(&crate::load_path(&dir).unwrap(), expected); + for (path, bytes) in &old_objects { + assert_eq!(&std::fs::read(dir.join(path)).unwrap(), bytes, "old object removed before durable publication: {path}"); + } + + fs.synced.borrow_mut().clear(); + writer.save_dir_with(&new, &dir, &SaveOptions::default(), &fs).unwrap(); + assert_eq!(crate::load_path(&dir).unwrap(), new); + let synced = fs.synced.borrow(); + let publication = synced.iter().rposition(|p| p == &dir).unwrap(); + for sub in ["tiles", "blobs"] { + assert!( + synced.iter().position(|p| p == &dir.join(sub)).is_some_and(|position| position < publication), + "retry must flush reused {sub} before its manifest" + ); + } + assert!(old_objects.iter().all(|(p, _)| !dir.join(p).exists()), "cleanup resumes after the durable retry"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 9ec92c2fba..5057e52168 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -127,11 +127,24 @@ impl<'a> ZipReader<'a> { if bytes.len() < 22 { return Err(FormatError::corrupt("not a zip archive (too short)")); } - // End of central directory: scan back over a possible comment. + // A comment may itself contain the signature. Only accept a record whose + // declared comment reaches the end of the archive (APPNOTE 4.3.16). let min = bytes.len().saturating_sub(22 + 65535); let eocd = (min..=bytes.len() - 22) .rev() - .find(|&i| u32_at(bytes, i).ok() == Some(EOCD_SIG)) + .find(|&i| { + if u32_at(bytes, i).ok() != Some(EOCD_SIG) { + return false; + } + let comment_end = u16_at(bytes, i + 20).ok().and_then(|length| i.checked_add(22)?.checked_add(usize::from(length))); + let directory_end = u32_at(bytes, i + 16) + .ok() + .zip(u32_at(bytes, i + 12).ok()) + .and_then(|(offset, size)| usize::try_from(offset).ok()?.checked_add(usize::try_from(size).ok()?)); + // In the supported non-ZIP64 layout, the central directory immediately + // precedes this record. A zeroed signature in a comment is not an empty ZIP. + comment_end == Some(bytes.len()) && directory_end == Some(i) + }) .ok_or_else(|| FormatError::corrupt("zip end-of-central-directory not found"))?; let count = u16_at(bytes, eocd + 10)? as usize; let cd_size = u32_at(bytes, eocd + 12)? as usize; @@ -263,6 +276,56 @@ mod tests { assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); } + fn archive_with_comment(comment: &[u8]) -> Vec { + let mut writer = ZipWriter::new(); + writer.add("manifest.json", b"project content").unwrap(); + let mut bytes = writer.finish().unwrap(); + let length_offset = bytes.len() - 2; + bytes[length_offset..].copy_from_slice(&u16::try_from(comment.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(comment); + bytes + } + + #[test] + fn directory_signature_inside_comment_does_not_hide_entries() { + for length in [22, 32, usize::from(u16::MAX)] { + let mut comment = vec![0; length]; + comment[..4].copy_from_slice(&EOCD_SIG.to_le_bytes()); + let bytes = archive_with_comment(&comment); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.entries.len(), 1, "comment length {length}"); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + } + + #[test] + fn archive_comments_support_empty_short_and_maximum_lengths() { + for length in [0, 1, 21, 22, 100, usize::from(u16::MAX)] { + let bytes = archive_with_comment(&vec![b'x'; length]); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content", "comment length {length}"); + } + } + + #[test] + fn central_directory_digital_signature_remains_supported() { + let mut bytes = archive_with_comment(b"signed archive"); + let eocd = bytes.len() - 22 - b"signed archive".len(); + let signature = [0x50, 0x4b, 0x05, 0x05, 3, 0, 1, 2, 3]; + let size = u32_at(&bytes, eocd + 12).unwrap() + signature.len() as u32; + bytes[eocd + 12..eocd + 16].copy_from_slice(&size.to_le_bytes()); + bytes.splice(eocd..eocd, signature); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + + #[test] + fn truncated_archive_comment_is_rejected() { + let mut bytes = archive_with_comment(b"comment"); + bytes.pop(); + assert!(ZipReader::new(&bytes).is_err()); + } + #[test] fn crc_matches_bitwise_reference() { // Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross diff --git a/crates/format/tests/analysis.rs b/crates/format/tests/it/analysis.rs similarity index 100% rename from crates/format/tests/analysis.rs rename to crates/format/tests/it/analysis.rs diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/it/autosave.rs similarity index 80% rename from crates/format/tests/autosave.rs rename to crates/format/tests/it/autosave.rs index 29905d7ac5..24d61d50d3 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/it/autosave.rs @@ -1,6 +1,6 @@ //! Background autosave and crash recovery. -mod common; +use crate::common; use std::sync::Arc; use common::*; @@ -188,6 +188,56 @@ fn recovered_entries_survive_a_second_crash_until_saved_or_closed() { std::fs::remove_dir_all(dir).unwrap(); } +/// #3438: a session about to end without asking (its window's graphics device was lost) writes +/// everything queued before it returns, so the next launch recovers it. +#[test] +fn flush_writes_every_queued_autosave_before_returning() { + let dir = temp_dir("flush"); + let (a, b) = (version(1, 3, ColorMode::Rgb), version(2, 5, ColorMode::Grayscale)); + let mut store = RecoveryStore::new(&dir); + store.autosave_checked(&Arc::new(a.clone()), 4, None).unwrap(); + store.autosave_checked(&Arc::new(b.clone()), 6, Some("/work/b.pcraft".into())).unwrap(); + let mut done = store.flush(); + done.sort_by_key(|(id, ..)| *id); + assert_eq!(done, vec![(1, 4, Ok(())), (2, 6, Ok(()))]); + // On disk while the store is still alive, not only once it is dropped. + let docs = recovered(&RecoveryStore::new(&dir)); + assert_eq!(docs.len(), 2); + assert!(docs.values().any(|d| *d == same(a.clone())) && docs.values().any(|d| *d == same(b.clone()))); + // A later autosave (trying a failed write again) starts a new worker on the same entry. + let newer = version(1, 4, ColorMode::Rgb); + store.autosave_checked(&Arc::new(newer.clone()), 5, None).unwrap(); + assert_eq!(store.flush(), vec![(1, 5, Ok(()))]); + let docs = recovered(&RecoveryStore::new(&dir)); + assert_eq!(docs.len(), 2, "the same entry, not a new one"); + assert!(docs.values().any(|d| *d == same(newer.clone())) && !docs.values().any(|d| *d == same(a.clone()))); + drop(store); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// #6464 review: a document saved or closed after a flush (the app stayed open because a recovery +/// write failed) still has its entry removed, so it doesn't come back at the next launch. +#[test] +fn discard_after_flush_removes_the_entry() { + let dir = temp_dir("discard-after-flush"); + let mut store = RecoveryStore::new(&dir); + store.autosave_checked(&Arc::new(version(1, 3, ColorMode::Rgb)), 4, None).unwrap(); + assert_eq!(store.flush(), vec![(1, 4, Ok(()))]); + assert_eq!(list_recovery(&dir).len(), 1); + store.discard(1).unwrap(); + assert!(list_recovery(&dir).is_empty(), "the flushed document's entry is gone"); + // And after a retry wrote the entry again through a new worker. + store.autosave_checked(&Arc::new(version(2, 5, ColorMode::Rgb)), 6, None).unwrap(); + store.flush(); + store.autosave_checked(&Arc::new(version(2, 6, ColorMode::Rgb)), 7, None).unwrap(); + store.flush(); + assert_eq!(list_recovery(&dir).len(), 1, "one entry per document"); + store.discard(2).unwrap(); + assert!(list_recovery(&dir).is_empty()); + drop(store); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn new_documents_never_overwrite_surviving_entries() { let dir = temp_dir("collision"); diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/it/common/mod.rs similarity index 99% rename from crates/format/tests/common/mod.rs rename to crates/format/tests/it/common/mod.rs index 8d5641d9cd..0c91e33e32 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/it/common/mod.rs @@ -154,6 +154,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { live: Some(LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [4.0, 0.0, 4.0, 0.0] }), cache: Some(cache.clone()), psd_raw: Some(blob(10, 30)), + density: 0.6, + feather: 3.5, }), ); shape.label = LabelColor::Green; diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/it/corruption.rs similarity index 96% rename from crates/format/tests/corruption.rs rename to crates/format/tests/it/corruption.rs index d293abed1e..fd2d0602f6 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/it/corruption.rs @@ -1,6 +1,6 @@ //! Damaged or hostile bundles must fail cleanly, never panic. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_format::*; @@ -94,6 +94,24 @@ fn bad_manifest_json() { assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_)))); } +/// A number past f32's range would load as infinity, and every later save would then fail +/// (#6862): it is refused when the file is opened, naming the value. +#[test] +fn out_of_range_number_refused_on_open() { + let b = rebuild(&sample(), |n, d| { + Some(if n == "manifest.json" { + let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap(); + v["document"]["layers"][0]["opacity"] = serde_json::json!(1e39); + serde_json::to_vec(&v).unwrap() + } else { + d + }) + }); + let Err(e) = load_from_bytes(&b) else { panic!("a manifest with opacity 1e39 opened") }; + assert!(matches!(e, FormatError::Corrupt(_)), "{e}"); + assert!(e.to_string().contains("document.layers[0].opacity"), "{e}"); +} + /// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the /// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for /// lacking a format version. diff --git a/crates/format/tests/directory_symlinks.rs b/crates/format/tests/it/directory_symlinks.rs similarity index 93% rename from crates/format/tests/directory_symlinks.rs rename to crates/format/tests/it/directory_symlinks.rs index 0302593f49..0a8d7d2462 100644 --- a/crates/format/tests/directory_symlinks.rs +++ b/crates/format/tests/it/directory_symlinks.rs @@ -1,6 +1,6 @@ //! Directory cleanup only owns content-addressed objects; unrelated files survive. -mod common; +use crate::common; use common::temp_dir; use photocraft_color::{ColorMode, SampleType}; @@ -121,7 +121,7 @@ mod permissions { } #[test] - fn denied_stale_preview_cleanup_preserves_the_previous_manifest() { + fn denied_stale_preview_cleanup_is_retried_after_a_successful_save() { let root = temp_dir("denied-preview-cleanup"); let bundle = root.join("project.pcraft"); let mut writer = PcraftWriter::new(); @@ -139,12 +139,14 @@ mod permissions { return; } let mut changed = empty_doc(); - changed.name = "must not commit".into(); + changed.name = "committed despite housekeeping failure".into(); let result = writer.save_dir(&changed, &bundle, &SaveOptions::default()); drop(restricted); - assert!(result.is_err(), "a required pre-commit cleanup failure is reported"); - assert_eq!(std::fs::read(preview).unwrap(), b"stale composite"); - assert_eq!(load_path(&bundle).unwrap(), original); + result.expect("a stale preview must not turn a committed save into an error"); + assert_eq!(std::fs::read(&preview).unwrap(), b"stale composite"); + assert_eq!(load_path(&bundle).unwrap(), changed); + writer.save_dir(&changed, &bundle, &SaveOptions::default()).unwrap(); + assert!(!preview.exists(), "a later save retries stale-preview cleanup"); std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/format/tests/it/float_roundtrip.rs b/crates/format/tests/it/float_roundtrip.rs new file mode 100644 index 0000000000..da6e7626d1 --- /dev/null +++ b/crates/format/tests/it/float_roundtrip.rs @@ -0,0 +1,27 @@ +//! `.pcraft` is lossless for f64: every coordinate reloads bit-for-bit. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; + +#[test] +fn f64_coordinates_reload_bit_exact() { + let x = 11.183221215612905_f64; + let y = 0.1 + 0.2; + let mut doc = Document::new("Floats", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Shape", + LayerContent::Shape(photocraft_doc::ShapeLayer { + path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(x, y), (40.0, 10.0), (25.0, 40.0)])]), + ..Default::default() + }), + )); + doc.guides.horizontal.push(x); + doc.guides.vertical.push(y); + doc.ruler_origin = [x, y]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.guides.horizontal[0].to_bits(), x.to_bits()); + assert_eq!(back.guides.vertical[0].to_bits(), y.to_bits()); + assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); + assert_eq!(back, doc); +} diff --git a/crates/format/tests/layer_labels.rs b/crates/format/tests/it/layer_labels.rs similarity index 100% rename from crates/format/tests/layer_labels.rs rename to crates/format/tests/it/layer_labels.rs diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs new file mode 100644 index 0000000000..6c822f4282 --- /dev/null +++ b/crates/format/tests/it/main.rs @@ -0,0 +1,11 @@ +//! The crate's integration tests, linked into one test binary. + +mod analysis; +mod autosave; +mod common; +mod corruption; +mod directory_symlinks; +mod float_roundtrip; +mod layer_labels; +mod roundtrip; +mod smart_contents; diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/it/roundtrip.rs similarity index 82% rename from crates/format/tests/roundtrip.rs rename to crates/format/tests/it/roundtrip.rs index f78e93947e..72de346c32 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! doc → .pcraft → doc equality, incremental saves, ids, previews. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; @@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() { assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::(), text.text.len()); } +#[test] +fn text_run_baseline_roundtrips() { + use photocraft_doc::text::FontBaseline; + let mut doc = Document::new("scripts", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Text", + LayerContent::Text(TextLayer { + text: "H2O2".into(), + runs: vec![ + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Subscript, ..Default::default() } }, + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Superscript, ..Default::default() } }, + ], + ..Default::default() + }), + )); + let loaded = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") }; + let baselines: Vec = text.char_runs().iter().map(|r| r.style.baseline).collect(); + assert_eq!(baselines, [FontBaseline::Normal, FontBaseline::Subscript, FontBaseline::Normal, FontBaseline::Superscript]); +} + #[test] fn zip_incremental_only_new_tiles() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); @@ -488,6 +511,11 @@ fn vector_fields_roundtrip_and_default_when_absent() { for l in layers.as_array_mut().unwrap() { l.as_object_mut().unwrap().remove("vector_mask"); let c = l["content"].as_object_mut().unwrap(); + // A shape's density and feather (#5846) came later still. + if c.get("kind").and_then(|k| k.as_str()) == Some("shape") { + c.remove("density"); + c.remove("feather"); + } for k in ["path", "stroke", "live"] { c.remove(k); } @@ -500,6 +528,15 @@ fn vector_fields_roundtrip_and_default_when_absent() { let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); assert!(old.paths.is_empty() && old.work_path.is_none()); assert!(old.layers.iter().all(|l| l.vector_mask.is_none())); + let shapes: Vec<_> = old + .layers + .iter() + .filter_map(|l| match &l.content { + photocraft_format::manifest::ContentM::Shape { density, feather, .. } => Some((*density, *feather)), + _ => None, + }) + .collect(); + assert!(!shapes.is_empty() && shapes.iter().all(|s| *s == (1.0, 0.0)), "{shapes:?}"); } /// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written @@ -579,6 +616,7 @@ fn channel_restrictions_and_bevel_elements_roundtrip() { altitude: 40.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Screen, 0.7), highlight_color: photocraft_color::Color::WHITE, shadow: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Multiply, 0.6), @@ -730,6 +768,34 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() assert_eq!(back, doc); } +/// #4957: a directory save holds the bundle's lock for its whole transaction. While another +/// writer holds it, a save waits, then fails without writing anything; once it is released the +/// save goes ahead. +#[test] +fn directory_save_waits_for_the_bundle_lock() { + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("lock"); + let other = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("save.lock")).unwrap(); + // A file system without file locks saves unlocked (`lock_bundle`), so there's nothing to test. + match other.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::Error(e)) if e.kind() == std::io::ErrorKind::Unsupported => return, + Err(std::fs::TryLockError::Error(e)) => panic!("locking save.lock failed: {e}"), + Err(std::fs::TryLockError::WouldBlock) => panic!("save.lock is already locked"), + } + let mut w = PcraftWriter::new(); + w.set_lock_wait(std::time::Duration::from_millis(100)); + let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(e.to_string().contains("in progress"), "{e}"); + assert!(!dir.join("manifest.json").exists(), "nothing written while locked"); + assert!(!dir.join("tiles").exists() || std::fs::read_dir(dir.join("tiles")).unwrap().count() == 0); + other.unlock().unwrap(); + w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + drop(other); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn variables_follow_layer_id_remapping() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); @@ -751,3 +817,74 @@ fn variables_follow_layer_id_remapping() { let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); } + +/// Removing previews is post-commit housekeeping, not part of publishing the new document. +#[test] +fn directory_save_without_previews_removes_stale_files_after_commit() { + let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("preview-cleanup"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + doc.name = "after".into(); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(!dir.join("thumb.png").exists()); + assert!(!dir.join("composite/preview.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A real Windows handle denying delete-sharing blocks manifest replacement after preparation. +#[cfg(windows)] +#[test] +fn failed_manifest_commit_preserves_omitted_previews() { + use std::os::windows::fs::OpenOptionsExt; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::with_background("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, depth, photocraft_color::Color::WHITE); + let dir = temp_dir("locked-manifest-previews"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + let old_doc = doc.clone(); + let paths = ["manifest.json", "thumb.png", "composite/preview.png"]; + let before: Vec<_> = paths.iter().map(|p| std::fs::read(dir.join(p)).unwrap()).collect(); + // FILE_SHARE_READ | FILE_SHARE_WRITE; deliberately omit FILE_SHARE_DELETE. + let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(dir.join("manifest.json")).unwrap(); + doc.name = "after".into(); + let error = writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(matches!(error, FormatError::Io(_)), "expected a real commit I/O failure: {error}"); + for (path, bytes) in paths.iter().zip(&before) { + assert_eq!(std::fs::read(dir.join(path)).ok().as_ref(), Some(bytes), "saved file lost after failed commit: {path}"); + } + assert_eq!(load_path(&dir).unwrap(), old_doc); + drop(held); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(!dir.join("thumb.png").exists()); + assert!(!dir.join("composite/preview.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } +} + +#[cfg(windows)] +#[test] +fn locked_stale_preview_does_not_fail_committed_save() { + use std::os::windows::fs::OpenOptionsExt; + + let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("locked-stale-preview"); + let mut writer = PcraftWriter::new(); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + writer.save_dir(&doc, &dir, &opts).unwrap(); + let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(dir.join("thumb.png")).unwrap(); + doc.name = "after".into(); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + assert!(dir.join("thumb.png").exists(), "locked leftover remains until a later cleanup"); + assert!(!dir.join("composite/preview.png").exists()); + drop(held); + writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert!(!dir.join("thumb.png").exists()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/format/tests/smart_contents.rs b/crates/format/tests/it/smart_contents.rs similarity index 100% rename from crates/format/tests/smart_contents.rs rename to crates/format/tests/it/smart_contents.rs diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 9c8f138747..8ba2bdd043 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -555,7 +555,7 @@ impl BezierMesh { /// Exact when the patches came from an unedited split; otherwise the outer control points are /// kept and the inner handles extrapolated. pub fn remove_split_u(&mut self, k: usize) -> bool { - if k == 0 || k + 1 >= self.us.len() { + if k == 0 || k >= self.us.len().saturating_sub(1) { return false; } let a = (self.us[k] - self.us[k - 1]) / (self.us[k + 1] - self.us[k - 1]); @@ -1275,6 +1275,26 @@ mod tests { } } + #[test] + fn remove_split_u_rejects_out_of_range_indices() { + let original = BezierMesh::identity(B, 2, 3); + for k in [0, original.us.len() - 1, original.us.len(), usize::MAX - 1, usize::MAX] { + let mut mesh = original.clone(); + assert!(!mesh.remove_split_u(k), "index {k}"); + assert_eq!(mesh, original, "index {k} must leave the mesh unchanged"); + } + } + + #[test] + fn remove_split_v_rejects_out_of_range_indices() { + let original = BezierMesh::identity(B, 2, 3); + for k in [0, original.vs.len() - 1, original.vs.len(), usize::MAX - 1, usize::MAX] { + let mut mesh = original.clone(); + assert!(!mesh.remove_split_v(k), "index {k}"); + assert_eq!(mesh, original, "index {k} must leave the mesh unchanged"); + } + } + #[test] fn splits_are_exact_and_removable() { let mut m = BezierMesh::identity(B, 1, 1); diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 6a0b649cd7..81b51f4c35 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -27,3 +27,5 @@ wgpu = { version = "30" } pollster = "1" # Shape layers in the effect parity tests. photocraft-vector = { workspace = true } +# A document-tagged CMYK profile in the tile conversion test. +photocraft-cms = { workspace = true } diff --git a/crates/gpu/examples/zero_block_probe.rs b/crates/gpu/examples/zero_block_probe.rs new file mode 100644 index 0000000000..ef9a5b44a2 --- /dev/null +++ b/crates/gpu/examples/zero_block_probe.rs @@ -0,0 +1,195 @@ +//! Probe: do textures uploaded like egui-wgpu's (TEXTURE_BINDING | COPY_DST, one full +//! `write_texture`) read back exactly what was written? Uploads mostly-transparent images with a +//! few opaque texels, samples every texel in a render pass, reads the target back and counts +//! texels that differ. Between uploads it frees render targets cleared to red, so stale memory is +//! red if the driver hands it back without honouring the copy. +//! +//! cargo run --release -p photocraft-gpu --example zero_block_probe -- [dx12|vulkan|gl] [rounds] [guard] [adapter=] +//! +//! `adapter=` probes the first adapter whose name contains `` (case-insensitive), e.g. +//! `adapter=iris` on a machine that also has a discrete GPU; without it wgpu picks its default adapter. + +use wgpu::util::DeviceExt; + +const SHADER: &str = r" +@group(0) @binding(0) var t: texture_2d; +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = array(vec2(-1.0, -3.0), vec2(-1.0, 1.0), vec2(3.0, 1.0)); + return vec4(p[i], 0.0, 1.0); +} +@fragment fn fs(@builtin(position) p: vec4) -> @location(0) vec4 { + return textureLoad(t, vec2(p.xy), 0); +} +"; + +fn main() { + let args: Vec = std::env::args().collect(); + let backends = match args.get(1).map(String::as_str) { + Some("vulkan") => wgpu::Backends::VULKAN, + Some("gl") => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let rounds: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300); + // `guard`: transparent texels are uploaded as (0, 0, 0, 1), as `photocraft_ui_egui::texel_guard` does. + let guard = args.get(3).is_some_and(|a| a == "guard"); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapter = match args.iter().find_map(|a| a.strip_prefix("adapter=")).map(str::to_lowercase) { + Some(want) => { + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + let names: Vec = adapters.iter().map(|a| a.get_info().name).collect(); + adapters + .into_iter() + .find(|a| a.get_info().name.to_lowercase().contains(&want)) + .unwrap_or_else(|| panic!("no adapter matches {want:?}; found {names:?}")) + } + None => pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).expect("adapter"), + }; + let info = adapter.get_info(); + println!("adapter: {} ({:?}) driver {} {}", info.name, info.backend, info.driver, info.driver_info); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).expect("device"); + + let fmt = wgpu::TextureFormat::Rgba8Unorm; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { format: fmt, blend: None, write_mask: wgpu::ColorWrites::ALL })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + let mut bad_textures = 0; + let mut bad_texels = 0usize; + for round in 0..rounds { + // Vary the size like the brush previews and icons do. + let (w, h) = (16 + (round as u32 * 37) % 300, 16 + (round as u32 * 13) % 60); + // Free some red render targets first: their memory is what a not-zeroed heap hands back. + for _ in 0..2 { + let rt = device.create_texture(&wgpu::TextureDescriptor { + label: Some("red-rt"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = rt.create_view(&Default::default()); + let mut enc = device.create_command_encoder(&Default::default()); + enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.03, b: 0.03, a: 1.0 }), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + queue.submit([enc.finish()]); + } + // The upload: transparent except a diagonal band of opaque grey, like a stroke strip. + let mut px = if guard { [0u8, 0, 0, 1].repeat((w * h) as usize) } else { vec![0u8; (w * h * 4) as usize] }; + for y in 0..h { + for x in 0..w { + if (x as i64 * h as i64 / w as i64 - y as i64).abs() < 3 { + let i = ((y * w + x) * 4) as usize; + px[i..i + 4].copy_from_slice(&[200, 200, 200, 255]); + } + } + } + let tex = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("egui-like"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &px, + ); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("target"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) }], + }); + let tview = target.create_view(&Default::default()); + let row = (w * 4).div_ceil(256) * 256; + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &tview, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + } + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None } }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit([enc.finish()]); + buf.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).expect("poll"); + let data = buf.slice(..).get_mapped_range().expect("mapped"); + let mut bad = 0; + let mut first = None; + for y in 0..h { + for x in 0..w { + let got = &data[(y * row + x * 4) as usize..(y * row + x * 4 + 4) as usize]; + let want = &px[((y * w + x) * 4) as usize..((y * w + x) * 4 + 4) as usize]; + if got != want { + bad += 1; + first.get_or_insert((x, y, got.to_vec())); + } + } + } + if bad > 0 { + bad_textures += 1; + bad_texels += bad; + if bad_textures <= 10 { + println!("round {round}: {w}x{h} texture has {bad} wrong texels, first {first:?}"); + } + } + } + println!("{bad_textures}/{rounds} textures read back wrong ({bad_texels} texels)"); +} diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..a6c0c63579 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -31,6 +31,13 @@ struct Op { map_origin: vec2, map_size: vec2, p4: vec4, + // fs_layerbatch's third extra layer and the rest of its second (see `batch_layer`); zero + // elsewhere. With them the record fills its 256-byte stride. + q0: vec4, + q1: vec4, + q2: vec4, + q3: vec4, + q4: vec4, }; const F_MASK: u32 = 1u; // layer has an enabled mask @@ -53,6 +60,7 @@ const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment res const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3 const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w +const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its colour at fill p0.x inside it × its alpha @group(0) @binding(0) var chunk: Chunk; @group(0) @binding(1) var op: Op; @@ -312,6 +320,9 @@ fn fill_is_special(mode: i32) -> bool { // colour (Hard Mix: (cb - f(1 - cs)) / (1 - f)) at `opacity`, Fill staying coverage over // transparency. Lab documents mix Normal in CIELAB; type layers mix in the text gamma space. fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32, gamma_on: bool) -> vec4 { + // The same code as fs_layerbatch_normal, so a layer gives the same bits whichever pass + // draws it (a render resumed from the prefix cache splits runs differently). + if (mode == M_NORMAL && !gamma_on && (op.flags & F_LAB) == 0u) { return composite_normal_fill(b, s, opacity, fill); } let f = clamp(fill, 0.0, 1.0); let special = f < 1.0 && fill_is_special(mode); let ab = b.a; @@ -340,6 +351,21 @@ fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32 return vec4(text_dec(wb * text_enc(b.rgb, g) + wbs * text_enc(bl, g) + ws * text_enc(s.rgb, g), g), ao); } +// composite_fill for Normal outside Lab documents and without text gamma: Fill is plain coverage +// and the blend is the source. Kept apart so fs_layerbatch_normal doesn't carry every other +// mode's code, which costs registers and so pixels in flight. +fn composite_normal_fill(b: vec4, s: vec4, opacity: f32, fill: f32) -> vec4 { + let ab = b.a; + let over = s.a * opacity * clamp(fill, 0.0, 1.0); + if (over <= 0.0) { return b; } + let ao = ab + (1.0 - ab) * over; + if (ao <= 0.0) { return vec4(0.0); } + let wb = ab * (1.0 - over) / ao; + let wbs = ab * over / ao; + let ws = (1.0 - ab) * over / ao; + return vec4(wb * b.rgb + wbs * s.rgb + ws * s.rgb, ao); +} + // photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB). const D50: vec3 = vec3(0.96422, 1.0, 0.82521); fn lab_f(t: f32) -> f32 { @@ -649,13 +675,30 @@ fn adjust(c: vec3) -> vec3 { if (light > 0.0) { lc = c + (1.0 - c) * light; } let lh = rgb_to_hsl(lc); hh = rem_euclid(lh.x + (p0.x + dh) / 360.0, 1.0); - // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. - if (sat > 0.0) { - ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + var rgb: vec3; + if (hdr) { + // 32-bit: the chroma scaled around the lightness, unclipped, by Photoshop's factor + // in whole numbers (compose::adjust::hue_saturation_in, hdr_saturation_factor). + var k: f32; + if (sat <= 0.0) { + k = 1.0 - floor(-sat * 256.0 + 1e-4) / 256.0; + } else { + let steps = min(u32(floor(sat * 254.0 + 1e-4)), 254u); + k = f32(min(65280u / (255u - steps), 32767u)) / 256.0; + } + let d = (max(max(lc.r, lc.g), lc.b) - min(min(lc.r, lc.g), lc.b)) * k; + let lo = lh.z - d / 2.0; + let hi = lh.z + d / 2.0; + rgb = vec3(hue_ch(lo, hi, hh + 1.0 / 3.0), hue_ch(lo, hi, hh), hue_ch(lo, hi, hh - 1.0 / 3.0)); } else { - ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. + if (sat > 0.0) { + ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + } else { + ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + } + rgb = hsl_to_rgb(hh, ss, lh.z); } - var rgb = hsl_to_rgb(hh, ss, lh.z); // compose::adjust::range_lightness: towards the brightest channel when raised, the darkest when lowered. let rl = clamp(dl, -1.0, 1.0); if (rl > 0.0) { @@ -812,12 +855,9 @@ fn fs_mask(in: VOut) -> @location(0) vec4 { return c; } -// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. -@fragment -fn fs_blend(in: VOut) -> @location(0) vec4 { - let p = local(in.pos); - let b = textureLoad(tex_a, p, 0); - var s = textureLoad(tex_b, p, 0); +// blend_into(backdrop = b, src = s, mode, opacity) at chunk pixel p, including Dissolve. +fn blend_px(p: vec2, b: vec4, src: vec4) -> vec4 { + var s = src; if (s.a <= 0.0) { return b; } if (op.mode == M_DISSOLVE) { s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity * op.fill); @@ -826,6 +866,85 @@ fn fs_blend(in: VOut) -> @location(0) vec4 { return composite_fill(op.mode, b, s, op.opacity, op.fill, (op.flags & F_TEXT_GAMMA) != 0u); } +// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. +@fragment +fn fs_blend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + return blend_px(p, textureLoad(tex_a, p, 0), textureLoad(tex_b, p, 0)); +} + +// fs_content then fs_blend in one pass: the layer's own pixels (times its mask) blended straight +// onto A, without writing them to a chunk texture and reading them back. +@fragment +fn fs_layerblend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + return blend_px(p, textureLoad(tex_a, p, 0), s); +} + +// The pixel at document pixel d of an extra layer of fs_layerbatch: an unmasked plain layer whose +// pixels are in texture `t` (0: tex_b, 1: tex_c, 2: map_tex). Three vec4 of the record describe +// it: geo = (texture origin x, y, size w, h), color = its colour outside the texture, +// prm = (opacity, fill, mode, 1 when it has a texture), all whole numbers stored as floats. +fn batch_texel(t: i32, d: vec2, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let q = d - vec2(geo.xy); + if (prm.w > 0.5 && inside(q, vec2(geo.zw))) { + if (t == 0) { + return textureLoad(tex_b, q, 0); + } else if (t == 1) { + return textureLoad(tex_c, q, 0); + } + return textureLoad(map_tex, q, 0); + } + return color; +} + +// One extra layer of fs_layerbatch (see batch_texel) blended onto b at document pixel d. +fn batch_layer(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_fill(i32(prm.z), b, s, prm.x, prm.y, false); +} + +// batch_layer of a Normal layer outside Lab documents. +fn batch_layer_normal(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_normal_fill(b, s, prm.x, prm.y); +} + +// fs_layerblend of this pass's layer, then `op.kind` (at most 3) more plain layers: consecutive +// LayerBlend passes the executor draws as one, reading and writing the chunk once for all of them. +@fragment +fn fs_layerbatch(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = blend_px(p, textureLoad(tex_a, p, 0), s); + if (op.kind >= 1) { b = batch_layer(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + +// fs_layerbatch for a run of Normal layers without text gamma, outside Lab documents. +@fragment +fn fs_layerbatch_normal(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = textureLoad(tex_a, p, 0); + if (s.a > 0.0) { b = composite_normal_fill(b, s, op.opacity, op.fill); } + if (op.kind >= 1) { b = batch_layer_normal(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer_normal(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer_normal(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + // composite_atop(base = A, src = B): blend as if the base were opaque, keep its alpha. @fragment fn fs_atop(in: VOut) -> @location(0) vec4 { @@ -833,6 +952,9 @@ fn fs_atop(in: VOut) -> @location(0) vec4 { let base = textureLoad(tex_a, p, 0); if (base.a <= 0.0) { return base; } let s = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(base, vec4(s.rgb, base.a), dissolve_noise(doc_px(p)) < s.a * op.opacity); + } let r = composite_g(op.mode, vec4(base.rgb, 1.0), s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); return vec4(r.rgb, base.a); } @@ -848,6 +970,7 @@ fn fs_adjust(in: VOut) -> @location(0) vec4 { } // Mix an adjusted result B back over the original A with blend mode, opacity and mask. +// Both ordinary and clipped adjustments use this pass, retaining the original alpha. @fragment fn fs_adjmix(in: VOut) -> @location(0) vec4 { let p = local(in.pos); @@ -855,6 +978,9 @@ fn fs_adjmix(in: VOut) -> @location(0) vec4 { let k = op.opacity * mask_value(doc_px(p)); if (k <= 0.0) { return b; } let a = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(b, vec4(a.rgb, b.a), dissolve_noise(doc_px(p)) < k); + } let bl = blend_rgb(op.mode, b.rgb, a.rgb); return vec4(b.rgb + (bl - b.rgb) * k, b.a); } @@ -1016,6 +1142,16 @@ fn fx_color(d: vec2) -> vec4 { return op.color; } +// effects::paint: blend an effect's colour `s` (alpha = colour alpha × coverage × opacity) into +// `dst` at document pixel `d`. Dissolve takes the effect fully or not at all (effects::dissolves_in). +fn fx_composite(d: vec2, dst: vec4, s: vec4) -> vec4 { + if (op.mode == M_DISSOLVE) { + if (dissolve_noise(d) < s.a) { return composite(M_NORMAL, dst, vec4(s.rgb, 1.0), 1.0); } + return dst; + } + return composite(op.mode, dst, s, 1.0); +} + // Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base // for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer // before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the @@ -1034,7 +1170,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { let a = textureLoad(tex_b, p, 0).a; let s = textureLoad(tex_c, p, 0); if (s.a <= 0.0 || a <= INSIDE_EPS) { return c; } - return composite(op.mode, c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0)), 1.0); + return fx_composite(doc_px(p), c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0))); } case 5: { let a = textureLoad(tex_b, p, 0).a; @@ -1083,12 +1219,12 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4 { let k = min(m * op.p0.x, 1.0) * op.opacity; if (k <= 0.0) { return dst; } let t = 1.0 - clamp(m, 0.0, 1.0); - return composite(op.mode, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k), 1.0); + return fx_composite(d, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k)); } let k = m * op.opacity; if (k <= 0.0) { return dst; } let c = fx_color(d); - return composite(op.mode, dst, vec4(c.rgb, c.a * k), 1.0); + return fx_composite(d, dst, vec4(c.rgb, c.a * k)); } // Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in @@ -1125,7 +1261,12 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4 { k = clamp((1.0 - clamp(a, 0.0, 1.0)) / (1.0 - lay_a), 0.0, 1.0); } } - let share = k * op.opacity * (1.0 - cover); + var cov = k * op.opacity; + if (op.mode == M_DISSOLVE) { + // effects.rs: a Dissolve stroke covers each pixel fully or not at all. + cov = select(0.0, 1.0, cov > 0.0 && dissolve_noise(d) < cov); + } + let share = cov * (1.0 - cover); if (op.kind == 1) { return vec4(cover + share, lay_a, 0.0, 0.0); } var acc = vec4(0.0); if (!first) { acc = textureLoad(layer_tex, p, 0); } @@ -1168,13 +1309,24 @@ fn mix_premul(a: vec4, b: vec4, k: f32) -> vec4 { fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let p = local(in.pos); var w = textureLoad(tex_a, p, 0); - let l = textureLoad(tex_b, p, 0); + var l = textureLoad(tex_b, p, 0); let c = textureLoad(tex_c, p, 0); + if ((op.flags & F_LAYER_SHAPE) != 0u) { + // fs_fxinit INIT_INSIDE then INIT_ALPHA, on the shape in B. + l = vec4(l.rgb, select(0.0, op.p0.x, l.a > INSIDE_EPS) * min(l.a, 1.0)); + } if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); } let atop = (op.flags & F_ATOP) != 0u; var before = c; if (atop) { before = vec4(c.rgb, 1.0); } - let o = mix_premul(before, w, op.opacity); + var o: vec4; + if (op.mode == M_DISSOLVE) { + // Match the CPU effects compositor: opacity gates the completed stack, not + // its individual colours or alpha. Use document coordinates across chunks. + o = select(before, w, dissolve_noise(doc_px(p)) < op.opacity); + } else { + o = mix_premul(before, w, op.opacity); + } if (atop) { if (c.a > 0.0) { return vec4(o.rgb, c.a); } return c; @@ -1329,7 +1481,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4 { } // One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for -// height-gradient scale `depth`, times `region`, through the gloss contour. +// height-gradient scale `depth`, times `region`, with the shading through the gloss contour. fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth; let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth; @@ -1337,14 +1489,15 @@ fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let len = sqrt(n.x * n.x + n.y * n.y + 1.0); let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len; let se = op.p0.w; - let k = shade - se; + var k = shade - se; + // effects::bevel_maps: the gloss contour maps the shading, a flat face's as the neutral (#7360). + if (op.p1.w > 0.5) { k = lut(0, shade) - lut(0, se); } var v = 0.0; if (op.p1.z > 0.5) { if (k <= 0.0) { v = clamp(-k / max(se, 1e-3), 0.0, 1.0) * region; } } else if (k > 0.0) { v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region; } - if (op.p1.w > 0.5) { v = lut(0, v); } return v; } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..db204a3bf4 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -203,6 +203,11 @@ impl B { self.fields.push((kind, reach_for(width))); In::Field(kind) } + /// A chiselled bevel's inside and outside fields, always asked for together ([`bevel_fields`] + /// computes them as one). + fn bevel_fields(&mut self, width: f32) -> [In; 2] { + [FieldKind::BevelInside, FieldKind::BevelOutside].map(|k| self.field(k, width)) + } /// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map /// are skipped. fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In { @@ -374,8 +379,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, let mut h = if bv.technique == BevelTechnique::Smooth { b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) } else { - let din = b.field(FieldKind::BevelInside, size); - let dout = b.field(FieldKind::BevelOutside, size); + 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. @@ -542,20 +546,51 @@ fn threaded_inside(kind: FieldKind) -> bool { /// fields run on all threads inside `distance_field`, from one window. That is also the window the CPU /// compositor computes them from, so a rebuilt field is exactly the one it gives. fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect, halo: i32) -> Vec { - if threaded_inside(kind) { - let win = out.inflate(halo).intersect(®ion); - if win.is_empty() { - return Vec::new(); - } - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - return if win == out { f } else { crop(&f, win, out) }; - } - let parts = par_map(bands(out, BAND.max(halo)), |band| { - let win = band.inflate(halo).intersect(®ion); - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - crop(&f, win, band) + let [f] = fields_in_windows(threaded_inside(kind), shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] }); - parts.concat() + f +} + +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`, with the +/// sharp edges of each window found once (the two kinds have the same radius and threading). +pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { + let kind = FieldKind::BevelInside; + fields_in_windows(threaded_inside(kind), shape, region, out, field_radius(kind, reach) + 1, |s, w, h| { + photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32) + }) +} + +/// The fields `compute` gives for a `w × h` window of the region-sized `shape`, over `out` (inside +/// `region`), each window grown by `halo`: `out` as one window when `whole` (the field spreads over +/// threads itself), else bands of it in parallel. +fn fields_in_windows( + whole: bool, + shape: &[f32], + region: Rect, + out: Rect, + halo: i32, + compute: impl Fn(Vec, usize, usize) -> [Vec; N] + Sync + Send, +) -> [Vec; N] { + let part = |r: Rect| -> [Vec; N] { + let win = r.inflate(halo).intersect(®ion); + if win.is_empty() { + return std::array::from_fn(|_| Vec::new()); + } + let f = compute(crop(shape, region, win), win.width() as usize, win.height() as usize); + if win == r { f } else { f.map(|f| crop(&f, win, r)) } + }; + if whole { + return part(out); + } + let parts = par_map(bands(out, BAND.max(halo)), part); + std::array::from_fn(|k| { + let mut f = Vec::with_capacity(parts.iter().map(|p| p[k].len()).sum()); + for p in &parts { + f.extend_from_slice(&p[k]); + } + f + }) } /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. @@ -682,6 +717,8 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut( surface_fp(&m.surface, h, pin); } format!("{:?}{:?}", l.vector_mask, l.effects).hash(h); + l.blend_if.hash(h); + l.advanced.hash(h); h.write_u8(0xfe); } let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -695,6 +732,7 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut( (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); surface_fp(&m.surface, &mut h, pin); } + format!("{:?}", layer.vector_mask).hash(&mut h); } _ => walk(layer, &mut h, pin), } @@ -721,6 +759,36 @@ mod tests { assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key); } + /// A styled group's effect shape follows its own vector mask and its children's Blend If and + /// Advanced Blending, so the cached shape must be rebuilt when any of them changes. + #[test] + fn group_key_follows_vector_mask_blend_if_and_advanced() { + use photocraft_doc::{BlendRange, Knockout, Path, PixelFormat, Subpath, VectorMask}; + let light = GlobalLight::default(); + let group = Layer::group("g", vec![Layer::raster("child", PixelFormat::RGBA8)]); + let base = group_key(&group, &light, &mut |_| {}); + + let mask = |x: f64| VectorMask::new(Path::new(vec![Subpath::polygon(&[(x, 0.0), (x + 10.0, 0.0), (x + 10.0, 10.0)])])); + let mut masked = group.clone(); + masked.vector_mask = Some(mask(0.0)); + let with_mask = group_key(&masked, &light, &mut |_| {}); + assert_ne!(base, with_mask, "adding the group's vector mask"); + masked.vector_mask = Some(mask(5.0)); + assert_ne!(with_mask, group_key(&masked, &light, &mut |_| {}), "moving the group's vector mask"); + + fn child_mut(g: &mut Layer) -> &mut Layer { + let LayerContent::Group(gr) = &mut g.content else { unreachable!() }; + &mut gr.children[0] + } + let mut blend_if = group.clone(); + child_mut(&mut blend_if).blend_if.ranges = vec![[BlendRange { black: [128, 128], white: [255, 255] }, BlendRange::FULL]]; + assert_ne!(base, group_key(&blend_if, &light, &mut |_| {}), "a child's Blend If"); + + let mut advanced = group.clone(); + child_mut(&mut advanced).advanced.knockout = Knockout::Deep; + assert_ne!(base, group_key(&advanced, &light, &mut |_| {}), "a child's Advanced Blending"); + } + #[test] fn windows_grow_by_reach_and_temps_are_reused() { let light = GlobalLight::default(); @@ -842,6 +910,40 @@ mod tests { assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + /// Both bevel fields from one window (sharp edges found once) are bit for bit the fields + /// computed one at a time, over the whole region and over a part of it. + #[test] + fn paired_bevel_fields_equal_the_fields_one_at_a_time() { + let region = Rect::new(-20, -10, 300, 290); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut shape = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0); + let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos(); + shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; + } + } + let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); + for reach in [3, 9, 40] { + for out in [region, Rect::new(40, 30, 170, 120), Rect::new(-20, 200, 300, 290)] { + let [inside, outside] = bevel_fields(reach, &shape, region, out); + assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); + assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + // The banded path (wasm) pairs them per band the same way. + let halo = field_radius(FieldKind::BevelInside, reach) + 1; + let banded = + fields_in_windows(false, &shape, region, out, halo, |s, w, h| photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32)); + for (k, kind) in [FieldKind::BevelInside, FieldKind::BevelOutside].into_iter().enumerate() { + let [one] = fields_in_windows(false, &shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] + }); + assert_eq!(bits(&banded[k]), bits(&one), "banded {kind:?}, reach {reach}, {out:?}"); + } + } + } + } + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop @@ -852,8 +954,9 @@ mod tests { use photocraft_color::{BlendMode, Color}; use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition}; let light = GlobalLight::default(); - let stroke = - |size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) }); + let stroke = |size, position| { + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK), overprint: false }) + }; // Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646. let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false); assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields); diff --git a/crates/gpu/src/health.rs b/crates/gpu/src/health.rs index cb1a116c4b..61aacf8b58 100644 --- a/crates/gpu/src/health.rs +++ b/crates/gpu/src/health.rs @@ -53,6 +53,8 @@ struct Inner { faulted: AtomicBool, fault: Mutex>, errors: AtomicU64, + /// wgpu itself reported the device lost (see [`DeviceHealth::device_lost`]). + device_lost: AtomicBool, } /// Shared health flag of one wgpu device. Cheap to clone; every clone sees the same state. @@ -84,6 +86,7 @@ impl DeviceHealth { (_, false) => message, }; log::error!("wgpu device lost ({reason:?}): {detail}"); + lost.0.device_lost.store(true, Ordering::Release); lost.mark(Fault::Lost(detail)); }); let errors = health.clone(); @@ -134,6 +137,32 @@ impl DeviceHealth { self.0.errors.load(Ordering::Relaxed) } + /// Whether wgpu itself reported the device lost: its device-lost callback ran, or polling it + /// panicked. Unlike a fault recorded with [`mark`](Self::mark) (a simulated loss, or an + /// uncaptured error that names one), nothing drawn with the device reaches the screen any + /// more, the window's own frames included (#3438). + pub fn device_lost(&self) -> bool { + self.0.device_lost.load(Ordering::Acquire) + } + + /// Non-blocking poll that runs even after a fault was recorded, so a device that is lost + /// later (a driver reset while the app sat idle or the PC slept) is noticed before the next + /// frame is drawn with it. Skipped once [`device_lost`](Self::device_lost). + pub fn probe(&self, device: &wgpu::Device) { + if self.device_lost() { + return; + } + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() { + self.lost_by_poll(); + } + } + + /// `Device::poll` panicked: wgpu found the device lost. + fn lost_by_poll(&self) { + self.0.device_lost.store(true, Ordering::Release); + self.mark(Fault::Lost("the device stopped responding".into())); + } + /// Block until `index` (or all submitted work) finished, unless the device is unusable. /// Returns whether the device is still healthy afterwards. `Device::poll` panics on a lost /// device, so a panic is caught and recorded as a loss (native only; the web has no blocking @@ -147,7 +176,7 @@ impl DeviceHealth { match poll { Ok(Ok(_)) => {} Ok(Err(e)) => log::warn!("wgpu poll: {e}"), - Err(_) => self.mark(Fault::Lost("the device stopped responding".into())), + Err(_) => self.lost_by_poll(), } self.is_ok() } @@ -158,7 +187,7 @@ impl DeviceHealth { return; } if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() { - self.mark(Fault::Lost("the device stopped responding".into())); + self.lost_by_poll(); } } } @@ -190,6 +219,8 @@ mod tests { assert_eq!(h.fault(), Some(Fault::Lost("reset".into()))); c.mark(Fault::Lost("later".into())); assert_eq!(h.fault().map(|f| f.to_string()).as_deref(), Some("GPU device lost: reset")); + // A recorded loss is not wgpu reporting the device lost (#3438). + assert!(!h.device_lost()); } #[test] diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 690d144d68..4d3fd8f93a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -42,6 +42,7 @@ mod fx; pub mod health; pub mod plan; mod prefix; +pub mod timing; use std::collections::HashMap; use std::num::NonZeroU64; @@ -55,6 +56,7 @@ use photocraft_raster::{Surface, Tile}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; +pub use timing::{GpuTimer, GpuTiming}; /// Accumulator format for intermediate buffers. Full float: discontinuous operations /// (Posterize, Threshold, Hard Mix, Dissolve) must land on the same side of their thresholds as @@ -68,7 +70,7 @@ const MAP16: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; pub const CHUNK: u32 = 1024; const STRIDE: u64 = 256; const CHUNK_UNIFORM: u64 = 16; -const OP_UNIFORM: u64 = 176; +const OP_UNIFORM: u64 = 256; /// GPU memory the effect-map cache may hold before evicting layers not drawn this frame. pub const FX_BUDGET: usize = 1536 << 20; /// Largest side of the square pages layer surfaces are stored in on the GPU (smaller when the @@ -107,6 +109,12 @@ pub struct ChunkOut<'a> { /// What a render did (for profiling and tests). #[derive(Clone, Copy, Debug, Default)] pub struct Stats { + /// How long the GPU spent executing a composite, once its timestamps came back. Because the + /// readback is asynchronous this belongs to an *earlier* render than the one returning it + /// (it carries that render's own pixel count, so the two are never confused), and it is + /// `None` unless [`Compositor::measure_gpu_time`] is on and a measurement has landed. + /// Always `None` from [`Compositor::encode`], which submits nothing of its own. + pub gpu_time: Option, pub passes: usize, pub chunks: usize, pub slots: u32, @@ -131,6 +139,10 @@ pub struct Stats { pub prefix_hits: usize, /// Planned prefix passes skipped, summed over the reused chunks. pub prefix_passes_skipped: usize, + /// Plain layers blended inside another layer's pass (`fs_layerbatch`), summed over chunks. + pub layers_batched: usize, + /// Batched passes drawn with Normal's code alone (`fs_layerbatch_normal`), summed over chunks. + pub normal_batches: usize, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -143,7 +155,9 @@ enum TexKind { Rgba16F, /// GRAY8 masks copied verbatim. R8Direct, - /// Other masks as R32F. + /// 16-bit masks as R16F (the precision 16-bit pixels get as RGBA16F). + R16F, + /// 32-bit masks as R32F. R32F, } @@ -154,7 +168,8 @@ impl TexKind { Role::Content | Role::Stroke if f.sample == SampleType::U8 => TexKind::Rgba8, Role::Content | Role::Stroke => TexKind::Rgba16F, Role::Mask if f == PixelFormat::GRAY8 => TexKind::R8Direct, - Role::Mask => TexKind::R32F, + Role::Mask if f.sample == SampleType::F32 => TexKind::R32F, + Role::Mask => TexKind::R16F, } } fn format(self) -> wgpu::TextureFormat { @@ -162,6 +177,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => wgpu::TextureFormat::Rgba8Unorm, TexKind::Rgba16F => wgpu::TextureFormat::Rgba16Float, TexKind::R8Direct => wgpu::TextureFormat::R8Unorm, + TexKind::R16F => wgpu::TextureFormat::R16Float, TexKind::R32F => wgpu::TextureFormat::R32Float, } } @@ -170,6 +186,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => 4, TexKind::Rgba16F => 8, TexKind::R8Direct => 1, + TexKind::R16F => 2, TexKind::R32F => 4, } } @@ -461,6 +478,10 @@ pub struct Compositor { staged: u64, /// Copies and render passes recorded since the last submit. recorded: u32, + /// Why a layer page texture couldn't be created this refresh (it then ends on the CPU). + layer_alloc: Option, + /// Bytes of pages dropped to admit new ones, not yet counted towards an intermediate submit. + admit_freed: u64, acc_format: wgpu::TextureFormat, /// Whether the effect-map pipelines could be built (else documents with layer effects are /// [`Unsupported`] and use the CPU compositor). @@ -469,6 +490,9 @@ pub struct Compositor { cmyk: u64, /// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy. health: Option, + /// Timestamp queries measuring how long the GPU spends on a refresh (see + /// [`Compositor::measure_gpu_time`]); `None` = not measured. + timer: Option, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -569,6 +593,25 @@ fn field_reaches(progs: &[fx::MapProgram]) -> HashMap { want } +/// Why a refresh whose layer pages don't fit GPU memory is drawn on the CPU. +pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU"; + +/// Which of `pages` (LRU clock, key, bytes) to drop, least recently used first, so `incoming` +/// more bytes fit `budget` on top of the `total` held now. Callers leave out pages that must stay. +fn lru_victims(mut pages: Vec<(u64, K, u64)>, total: u64, incoming: u64, budget: u64) -> Vec { + pages.sort_unstable_by_key(|p| p.0); + let mut total = total; + let mut out = Vec::new(); + for (_, k, bytes) in pages { + if total.saturating_add(incoming) <= budget { + break; + } + total = total.saturating_sub(bytes); + out.push(k); + } + out +} + /// Runs `f` inside GPU error scopes and returns the first error raised. /// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none /// is reported. @@ -700,10 +743,13 @@ impl Compositor { stamp: 0, staged: 0, recorded: 0, + layer_alloc: None, + admit_freed: 0, acc_format, effect_maps: map_error.is_none(), cmyk: 0, health: None, + timer: None, } .with_texture_limit(device.limits().max_texture_dimension_2d) } @@ -956,6 +1002,41 @@ impl Compositor { self.health.as_ref().and_then(DeviceHealth::fault) } + /// Measure how long the GPU spends executing each [`Compositor::render`], with timestamp + /// queries read back asynchronously (nothing waits for the GPU). Returns whether the device + /// supports it: without [`timing::FEATURES`] there is no measurement at all, and callers + /// report no GPU time rather than the encode-and-submit cost `composite_ms` already is. + /// + /// A measurement arrives a frame or two after the refresh it belongs to, so it carries that + /// refresh's own pixel count; take it with [`Compositor::take_gpu_timing`]. + pub fn measure_gpu_time(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> bool { + // `PHOTOCRAFT_NO_GPU_TIMING` turns it off: a kill switch for a driver whose timestamp + // queries misbehave, and the control arm for measuring what the queries themselves + // cost — the device is identical either way, so only the instrumentation varies. + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return false; + } + if self.timer.is_none() { + self.timer = timing::GpuTimer::new(device, queue); + } + self.timer.is_some() + } + + /// Whether [`Compositor::measure_gpu_time`] is on for this compositor. + pub fn measures_gpu_time(&self) -> bool { + self.timer.is_some() + } + + /// The most recent finished GPU measurement, leaving none behind (a stale number is never + /// reported twice as new). `None` while unmeasured, unsupported, or still in flight. + /// + /// [`Compositor::render`] already drains this into [`Stats::gpu_time`], so the app never + /// calls it; it is how a bench or a test collects a measurement without issuing another + /// render to carry it. + pub fn take_gpu_timing(&mut self) -> Option { + self.timer.as_mut().and_then(timing::GpuTimer::take) + } + /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning; a @@ -971,8 +1052,47 @@ impl Compositor { ) -> Result { let result = scoped_work(device, self.health.is_some(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); - let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; + if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) { + // The readback map completes on a poll. The app polls every frame, but a + // non-blocking poll here keeps the measurements coming whatever the caller does. + match &self.health { + Some(h) => h.poll(device), + None => { + let _ = device.poll(wgpu::PollType::Poll); + } + } + } + // Whatever came back since the last render travels out with this one's stats, so + // nothing has to reach back into the compositor for it afterwards. + let landed = self.timer.as_mut().and_then(timing::GpuTimer::take); + if let Some(t) = &mut self.timer { + let r = region.intersect(&doc.bounds()); + t.start(&mut encoder, r.width() as u64 * r.height() as u64); + } + let mut stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true); + if let Ok(s) = &mut stats { + s.gpu_time = landed; + } + if let Some(t) = &mut self.timer { + // A refresh that failed part way measures nothing: its spans describe work the + // caller is about to redo on the CPU. + match stats { + Ok(_) => t.finish(&mut encoder), + Err(_) => { + t.cancel(); + // The refresh is lost, but a measurement that came back with it is not: + // hand it to the next render rather than drop it on the fallback path. + if let Some(landed) = landed { + t.restore(landed); + } + } + } + } + let stats = stats?; queue.submit([encoder.finish()]); + if let Some(t) = &mut self.timer { + t.submitted(); + } Ok(stats) }); if result.is_err() { @@ -1067,10 +1187,22 @@ impl Compositor { self.stamp += 1; self.staged = 0; self.recorded = 0; + self.layer_alloc = None; + self.admit_freed = 0; // Only render owns submission. A caller may discard encode's command buffer, so it // cannot establish (or consume) a checkpoint whose GPU copy might never execute. - let checkpoint = if flush { self.prefix.prepare(doc, &plan) } else { None }; + // Runs of plain layers drawn as one pass each (`fs_layerbatch`). The grouping is the same + // for every render of this plan, so a layer's bits don't depend on the cache: renders + // capture and resume only where a run starts. + let runs = batch_runs(&plan); + let checkpoint = if flush { + let starts = run_starts(&runs); + let resumable: Vec<_> = plan.checkpoints.iter().filter(|c| starts.get(c.pass_end).copied().unwrap_or(false)).copied().collect(); + self.prefix.prepare(doc, &resumable) + } else { + None + }; // Effect maps of whole regions first (they may render group shapes through sub-plans); // regions larger than the limit are built per cell below. @@ -1078,7 +1210,7 @@ impl Compositor { let aprons: Vec = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect(); for (f, &pg) in plan.fx.iter().zip(&paged) { if !pg { - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?; } } let fr = self.frame_res(device, queue, canvas, &plan); @@ -1109,22 +1241,58 @@ impl Compositor { continue; } let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region); - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?; } let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); work += part.width() as u64 * part.height() as u64 * plan.passes.len() as u64; let mut batch = flush.then_some(&mut last_submit); - self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch); + self.run_cell(device, queue, encoder, doc.id, &plan, &runs, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch); // The device was lost in a submit inside the cell: issue no more work. if let Some(f) = self.fault() { return Err(Unsupported(f.to_string())); } + // Out of memory although every page this cell doesn't use was dropped: the work recorded + // since the last submit can still hold those pages. Submit it, wait until the GPU is done + // with it so their memory is released, and run the cell once more. `encode` can't + // submit, so there the refresh goes to the CPU straight away. + if flush && self.layer_alloc.as_deref() == Some(OVER_BUDGET) { + self.layer_alloc = None; + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); + #[cfg(not(target_arch = "wasm32"))] + match self.health.as_ref() { + Some(h) => { + h.wait(device, last_submit.clone()); + } + None => { + let _ = device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }); + } + } + self.staged = 0; + self.recorded = 0; + work = 0; + freed = 0; + self.admit_freed = 0; + stats.flushes += 1; + if let Some(f) = self.fault() { + return Err(Unsupported(f.to_string())); + } + let mut batch = Some(&mut last_submit); + self.run_cell(device, queue, encoder, doc.id, &plan, &runs, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch); + if let Some(f) = self.fault() { + return Err(Unsupported(f.to_string())); + } + } + // A page didn't fit even after evicting: this refresh goes to the CPU, the next one + // tries the GPU again (#6563). + if let Some(e) = self.layer_alloc.take() { + return Err(Unsupported(e)); + } let (evicted, bytes) = self.evict(); stats.evicted += evicted; - freed += bytes; + freed = freed.saturating_add(bytes.saturating_add(std::mem::take(&mut self.admit_freed))); if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { - submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); self.staged = 0; self.recorded = 0; work = 0; @@ -1175,22 +1343,7 @@ impl Compositor { /// whole-region effect maps drawn this frame. Returns how many textures were dropped and /// their bytes. fn evict(&mut self) -> (usize, u64) { - let (mut n, mut freed) = (0, 0u64); - let mut total = self.resident_bytes(); - if total > self.resident_budget { - let mut lru: Vec<(u64, ResKey, u64)> = - self.residents.iter().filter(|(_, r)| r.stamp != self.stamp).map(|(k, r)| (r.stamp, *k, r.bytes())).collect(); - lru.sort_unstable_by_key(|c| c.0); - for (_, k, bytes) in lru { - if total <= self.resident_budget { - break; - } - self.residents.remove(&k); - total = total.saturating_sub(bytes); - n += 1; - freed += bytes; - } - } + let (mut n, mut freed) = self.evict_residents(self.resident_budget, 0, None); let mut total = self.fx_cache_bytes(); let fx_budget = usize::try_from(self.fx_budget).unwrap_or(usize::MAX); if total > fx_budget { @@ -1216,6 +1369,67 @@ impl Compositor { (n, freed) } + /// Drop least recently used resident pages until `incoming` more bytes fit in `budget`. + /// Never drops what the current cell uses, nor `keep`. Returns how many pages were dropped + /// and their bytes. + fn evict_residents(&mut self, budget: u64, incoming: u64, keep: Option) -> (usize, u64) { + let stamp = self.stamp; + let pages: Vec<(u64, ResKey, u64)> = + self.residents.iter().filter(|(k, r)| r.stamp != stamp && Some(**k) != keep).map(|(k, r)| (r.stamp, *k, r.bytes())).collect(); + let (mut n, mut freed) = (0, 0u64); + for k in lru_victims(pages, self.resident_bytes(), incoming, budget) { + if let Some(r) = self.residents.remove(&k) { + n += 1; + freed += r.bytes(); + } + } + (n, freed) + } + + /// Create the page texture for `key`, `bytes` large. Least recently used pages make room in + /// the budget first; when the device is still out of memory, every page the current cell + /// doesn't use is dropped and creation is tried once more (#6563). `None` (with + /// `layer_alloc` set) when it still fails: `render` submits the recorded work, which can still + /// hold the dropped pages, and runs the cell once more; after that the CPU draws the refresh. + fn create_layer_texture( + &mut self, + device: &wgpu::Device, + desc: &wgpu::TextureDescriptor<'_>, + key: ResKey, + bytes: u64, + stats: &mut Stats, + ) -> Option { + let replaced = self.residents.get(&key).map_or(0, Resident::bytes); + let (n, dropped) = self.evict_residents(self.resident_budget, bytes.saturating_sub(replaced), Some(key)); + stats.evicted += n; + self.admit_freed = self.admit_freed.saturating_add(dropped); + // WebGPU reports errors asynchronously: its failures reach the health callbacks. + if cfg!(target_arch = "wasm32") { + return Some(device.create_texture(desc)); + } + for retry in [false, true] { + let (texture, error) = first_error(device, || device.create_texture(desc)); + match error { + None => return Some(texture), + Some(wgpu::Error::OutOfMemory { .. }) if !retry => { + let (n, dropped) = self.evict_residents(0, 0, Some(key)); + stats.evicted += n; + self.admit_freed = self.admit_freed.saturating_add(dropped); + } + Some(wgpu::Error::OutOfMemory { .. }) => { + self.layer_alloc = Some(OVER_BUDGET.into()); + return None; + } + Some(e) => { + self.layer_alloc = Some(format!("GPU layer texture failed: {e}")); + return None; + } + } + } + self.layer_alloc = Some(OVER_BUDGET.into()); + None + } + /// Per-frame resources of `plan`: the tile area of each pass's surfaces, its LUT and pattern. fn frame_res(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, canvas: Rect, plan: &Plan<'_>) -> FrameRes { let grid = tile_grid(canvas); @@ -1286,8 +1500,8 @@ impl Compositor { Bound { views, keys, maps } } - /// Run `plan` over the `chunks` of one page cell (document rects, at most a chunk square), - /// handing each finished chunk to `sink`. + /// Run `plan` (drawn as `runs`, its `batch_runs`) over the `chunks` of one page cell + /// (document rects, at most a chunk square), handing each finished chunk to `sink`. #[allow(clippy::too_many_arguments)] fn run_cell( &mut self, @@ -1296,6 +1510,7 @@ impl Compositor { encoder: &mut wgpu::CommandEncoder, doc: DocId, plan: &Plan<'_>, + runs: &[usize], paged: &[bool], cell: Cell, fr: &FrameRes, @@ -1314,6 +1529,10 @@ impl Compositor { .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); + // A page couldn't be created: draw nothing with the pages missing (the caller ends the refresh). + if self.layer_alloc.is_some() { + return; + } // Chunk pool. while self.pool.len() < plan.slots as usize { @@ -1348,7 +1567,13 @@ impl Compositor { } let (tex, mask) = bound.views[i]; let map = bound.maps[i].as_ref().map(|(_, r)| *r); - let w = op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map); + let run = runs.get(i).copied().unwrap_or(1); + let w = if run > 1 { + let extras: Vec<_> = (i + 1..i + run).filter_map(|j| Some((plan.passes.get(j)?, bound.views.get(j).and_then(|v| v.0).map(|t| t.1)))).collect(); + batch_op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), &extras) + } else { + op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map) + }; let off = (op_base + i as u64 * STRIDE) as usize; data[off..][..w.len()].copy_from_slice(&w); } @@ -1377,6 +1602,13 @@ impl Compositor { let lv = fr.luts.get(i).and_then(Option::as_ref).map_or(dummy, |(_, v)| v); let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v); let pat = fr.patterns.get(i).and_then(Option::as_ref).unwrap_or(dummy); + let run = runs.get(i).copied().unwrap_or(1); + if run > 1 { + // The extra layers' pixels in tex_b, tex_c and map_tex (`batch_layer`). + let extra = |e: usize| bound.views.get(i + e).and_then(|v| v.0).map_or(dummy, |(k, _)| resident_views[k]); + bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), extra(1), tv, mv, lv, extra(2), extra(3), pat]))); + continue; + } bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), slot(p.b), tv, mv, lv, slot(p.c), map, pat]))); } @@ -1390,8 +1622,10 @@ impl Compositor { stats.prefix_hits = stats.prefix_hits.saturating_add(1); stats.prefix_passes_skipped = stats.prefix_passes_skipped.saturating_add(resume); } + // Passes drawn inside the previous `fs_layerbatch` pass. + let mut batched_to = 0; for (i, p) in plan.passes.iter().enumerate() { - if i < resume { + if i < resume || i < batched_to { continue; } // Chunk textures, uniforms and bind groups outlive the submit, and the queue @@ -1399,7 +1633,7 @@ impl Compositor { if self.recorded >= FLUSH_OPS && let Some(last) = batch.as_deref_mut() { - submit_batch(device, queue, encoder, self.health.as_ref(), last); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), last); self.staged = 0; self.recorded = 0; stats.flushes += 1; @@ -1451,7 +1685,21 @@ impl Compositor { _ => {} } self.recorded += 1; - let target = &self.pool[p.dst as usize].1; + // A run draws as one pass into the slot its last layer writes. + let run = runs.get(i).copied().unwrap_or(1); + let (kernel, dst) = match plan.passes.get(i + run - 1) { + Some(last) if run > 1 => { + batched_to = i + run; + stats.layers_batched = stats.layers_batched.saturating_add(run - 1); + let kernel = plan.passes.get(i..i + run).map_or(Kernel::LayerBatch, batch_kernel); + if kernel == Kernel::LayerBatchNormal { + stats.normal_batches = stats.normal_batches.saturating_add(1); + } + (kernel, last.dst) + } + _ => (p.kernel, p.dst), + }; + let target = &self.pool[dst as usize].1; let clear = p.kernel == Kernel::Clear; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_compose_pass"), @@ -1472,7 +1720,7 @@ impl Compositor { if clear { continue; } - pass.set_pipeline(&self.kit.pipelines[&(p.kernel, self.acc_format)]); + pass.set_pipeline(&self.kit.pipelines[&(kernel, self.acc_format)]); pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height()); pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]); pass.set_bind_group(1, bg1[i].as_ref(), &[]); @@ -1503,7 +1751,8 @@ impl Compositor { region: Rect, stats: &mut Stats, ) -> Option<(ResKey, [i32; 4])> { - if region.is_empty() { + // An earlier page of this cell didn't fit: the refresh ends after the cell anyway. + if region.is_empty() || self.layer_alloc.is_some() { return None; } let format = surface.format(); @@ -1516,7 +1765,7 @@ impl Compositor { .get(&key) .is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk || r.default_bits != default_bits); if stale { - let texture = device.create_texture(&wgpu::TextureDescriptor { + let desc = wgpu::TextureDescriptor { label: Some("pc_compose_layer"), size: wgpu::Extent3d { width: region.width(), height: region.height(), depth_or_array_layers: 1 }, mip_level_count: 1, @@ -1525,7 +1774,9 @@ impl Compositor { format: kind.format(), usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], - }); + }; + let bytes = region.width() as u64 * region.height() as u64 * kind.bytes_per_pixel() as u64; + let texture = self.create_layer_texture(device, &desc, key, bytes, stats)?; let view = texture.create_view(&Default::default()); let default_nonzero = default_pixel.iter().any(|v| *v != 0.0); let mut r = Resident { @@ -1720,7 +1971,7 @@ impl Compositor { key: FxKey, region: Rect, stats: &mut Stats, - ) { + ) -> Result<(), Unsupported> { let layer = f.layer; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); @@ -1791,13 +2042,13 @@ impl Compositor { } // Present: kept or (re)inserted above. let stamp = self.stamp; - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; e.last_used = frame; e.stamp = stamp; e.doc = doc.id; e.tiles = [content_src.map(fx::snapshot), mask_src.map(fx::snapshot)]; if region.is_empty() { - return; + return Ok(()); } let t_trace = web_time_now(); @@ -1819,27 +2070,41 @@ impl Compositor { layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); let want = field_reaches(&progs); e.fields.retain(|k, _| want.contains_key(k)); - for (&kind, &reach) in &want { - let have = e.fields.get(&kind).map(|(r, _)| *r); - let out = match have { - Some(r) if r >= reach => { - if damage.is_empty() { - continue; - } - damage.inflate(fx::field_radius(kind, r)).intersect(®ion) + // Each field to recompute: over which rectangle, exact up to which reach. + let jobs: Vec<(FieldKind, Rect, i32)> = want + .iter() + .filter_map(|(&kind, &reach)| match e.fields.get(&kind).map(|(r, _)| *r) { + Some(r) if r >= reach => (!damage.is_empty()).then(|| (kind, damage.inflate(fx::field_radius(kind, r)).intersect(®ion), r)), + _ => Some((kind, region, reach)), + }) + .collect(); + // A chiselled bevel's two fields are asked for and kept together (`fx::bevel_fields`), so they + // are recomputed together, over one rectangle at one reach. + let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); + let (bevel_in, bevel_out) = (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)); + if bevel_in != bevel_out { + return Err(Unsupported(format!("bevel fields of `{}` out of step: inside {bevel_in:?}, outside {bevel_out:?}", layer.name))); + } + for (kind, out, reach) in jobs { + let fields = match kind { + FieldKind::BevelInside => { + let [inside, outside] = fx::bevel_fields(reach, &e.shape_cpu, region, out); + vec![(FieldKind::BevelInside, inside), (FieldKind::BevelOutside, outside)] } - _ => region, + // Computed with BevelInside, over the same rectangle at the same reach (checked above). + FieldKind::BevelOutside => continue, + _ => vec![(kind, fx::field(kind, reach, &e.shape_cpu, region, out))], }; - let reach = have.filter(|r| *r >= reach).unwrap_or(reach); - let v = fx::field(kind, reach, &e.shape_cpu, region, out); - if have.is_none_or(|r| r < reach) { - e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + for (kind, v) in fields { + if e.fields.get(&kind).is_none_or(|(r, _)| *r < reach) { + e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + } + if let Some((_, t)) = e.fields.get(&kind) { + t.write_r32(queue, region, out, &v); + } + staged += v.len() as u64 * 4; + stats.fx_pixels += out.width() as u64 * out.height() as u64; } - if let Some((_, t)) = e.fields.get(&kind) { - t.write_r32(queue, region, out, &v); - } - staged += v.len() as u64 * 4; - stats.fx_pixels += out.width() as u64 * out.height() as u64; } if let Some(t) = t_trace.filter(|_| !damage.is_empty()) { @@ -1854,7 +2119,7 @@ impl Compositor { if prog.maps == 0 { continue; } - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; let same = e.progs[i].key == prog.key && e.progs[i].maps.len() == prog.maps; let d = if same { damage } else { region }; if d.is_empty() { @@ -1869,6 +2134,7 @@ impl Compositor { stats.fx_programs += 1; self.run_program(device, queue, encoder, key, i, prog, d, stats); } + Ok(()) } /// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`. @@ -1955,6 +2221,38 @@ fn web_time_now() -> Option { /// The uniform record of a pass. fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { + words(&op_values(p, tex, mask, map)) +} + +/// The uniform record of the `fs_layerbatch` pass that draws `p` (whose texture and mask cover +/// `tex` and `mask`) and then `extras`, each an unmasked plain layer's `LayerBlend` pass with the +/// area its texture covers. Layout: see `batch_layer` in compose.wgsl. +fn batch_op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, extras: &[(&plan::Pass<'_>, Option<[i32; 4]>)]) -> Vec { + let mut v = op_values(p, tex, mask, None); + // Word offsets of the record's vec4s holding the extra layers, three each: p0, p1, p2 | p3, + // q0, q1 | q2, q3, q4. + const AT: [usize; 9] = [20, 24, 28, 32, 44, 48, 52, 56, 60]; + if let Some(kind) = v.get_mut(1) { + *kind = I(extras.len().min(AT.len() / 3) as i32); + } + for (e, (x, area)) in extras.iter().take(AT.len() / 3).enumerate() { + let r = area.unwrap_or([0; 4]); + let geo = [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]; + let prm = [x.opacity, x.fill, plan::mode_index(x.mode) as f32, if area.is_some() { 1.0 } else { 0.0 }]; + for (k, vals) in [geo, x.color, prm].into_iter().enumerate() { + let at = AT[e * 3 + k]; + for (c, f) in vals.into_iter().enumerate() { + if let Some(w) = v.get_mut(at + c) { + *w = F(f); + } + } + } + } + words(&v) +} + +/// The words of a pass's uniform record (`Op` in compose.wgsl). +fn op_values(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { let mut flags = p.flags; let (mut to, mut ts, mut mo, mut ms) = ([0; 2], [0; 2], [0; 2], [0; 2]); if let Some(r) = tex { @@ -2001,7 +2299,82 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m } v.extend([I(mpo[0]), I(mpo[1]), I(mps[0]), I(mps[1])]); v.extend(p.extra.iter().map(|f| F(*f))); - words(&v) + // q0..q4: only `fs_layerbatch` reads them (`batch_op_words`). + v.extend((0..20).map(|_| F(0.0))); + v +} + +/// Most layers one `fs_layerbatch` pass blends: its own and the three more its record holds. +const BATCH: usize = 4; + +/// For each pass, how many passes from it on the executor draws as one `fs_layerbatch` pass +/// (1: the pass alone). A run starts at a `LayerBlend` pass over the whole chunk (no clip) that +/// isn't a gradient (whose parameters use the words the extra layers go in), and continues while +/// the next pass is a `LayerBlend` of an unmasked plain layer (no text gamma, no Dissolve) onto +/// exactly what the previous pass wrote, a slot nothing else reads before it is written again. +/// A run never ends in the slot it reads from. +/// +/// The runs depend on the plan alone, never on the prefix cache: a compiler may give a layer +/// other bits batched than drawn alone (FXC on D3D12 does), and reusing a prefix must stay exact +/// (#5286). The cache captures and resumes only at `run_starts` instead. +fn batch_runs(plan: &Plan<'_>) -> Vec { + let passes = &plan.passes; + let n = passes.len(); + let reads = |p: &plan::Pass<'_>, s: plan::Slot| [p.a, p.b, p.c, p.d].contains(&Some(s)); + // feeds_next[i]: what pass i writes is read by pass i + 1 alone. + let feeds_next: Vec = (0..n) + .map(|i| { + let (Some(this), Some(next)) = (passes.get(i), passes.get(i + 1)) else { return false }; + let dst = this.dst; + if next.a != Some(dst) || [next.b, next.c, next.d].contains(&Some(dst)) { + return false; + } + for later in passes.iter().skip(i + 2) { + // A copy into a slot keeps what lies outside its rect: that is a read too. + if reads(later, dst) || (later.dst == dst && later.kernel == Kernel::CopyRect) { + return false; + } + if later.dst == dst { + return true; + } + } + dst != plan.root + }) + .collect(); + let lead = |p: &plan::Pass<'_>| p.kernel == Kernel::LayerBlend && p.clip.is_none() && !p.gradient; + let extra = |p: &plan::Pass<'_>| lead(p) && p.mask.is_none() && p.flags & plan::F_TEXT_GAMMA == 0 && p.mode != photocraft_color::BlendMode::Dissolve; + (0..n) + .map(|i| { + let Some(first) = passes.get(i).filter(|p| lead(p)) else { return 1 }; + let (mut k, mut best) = (1, 1); + while k < BATCH && feeds_next.get(i + k - 1).copied().unwrap_or(false) && passes.get(i + k).is_some_and(extra) { + k += 1; + if passes.get(i + k - 1).is_some_and(|p| Some(p.dst) != first.a) { + best = k; + } + } + best + }) + .collect() +} + +/// For each pass and the end of the plan, whether the executor draws `runs` (`batch_runs`) so +/// that a pass starts there rather than inside a run. +fn run_starts(runs: &[usize]) -> Vec { + let mut starts = vec![false; runs.len() + 1]; + let mut i = 0; + while let Some(s) = starts.get_mut(i) { + *s = true; + i += runs.get(i).copied().unwrap_or(1).max(1); + } + starts +} + +/// The pipeline that draws `run` (passes `batch_runs` joined): `fs_layerbatch_normal` when every +/// layer blends Normal without text gamma outside Lab documents, else `fs_layerbatch`. +fn batch_kernel(run: &[plan::Pass<'_>]) -> Kernel { + let normal = |p: &plan::Pass<'_>| p.mode == photocraft_color::BlendMode::Normal && p.flags & (plan::F_TEXT_GAMMA | plan::F_LAB) == 0; + if run.iter().all(normal) { Kernel::LayerBatchNormal } else { Kernel::LayerBatch } } /// Canvas rounded out to whole tiles. @@ -2034,9 +2407,14 @@ fn submit_batch( queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, health: Option<&DeviceHealth>, + timer: Option<&mut timing::GpuTimer>, last: &mut Option, ) { - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let mut done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + if let Some(t) = timer { + // One refresh, several command buffers: the measurement spans all of them. + t.split(&mut done, encoder); + } let prev = last.replace(queue.submit([done.finish()])); #[cfg(not(target_arch = "wasm32"))] if let Some(prev) = prev { @@ -2161,48 +2539,97 @@ fn convert_tiles(surface: &Surface, tiles: Vec<(TileCoord, Arc)>, kind: Te } } } + // CMYK and Lab tiles convert pixel by pixel through a colour transform (a CMYK tile took + // ~5 ms on one thread, the whole of a brush dab's refresh on an A4 CMYK page, #5108): those + // go in bands of rows, so even the one tile a dab touches spreads over the threads. Workers + // don't inherit this thread's CMYK profile scope, so each band enters it. + let bands = if colour_managed(&surface.format(), kind) { TILE_SIZE / MANAGED_BAND_ROWS } else { 1 }; + let space = photocraft_color::convert::active_cmyk_space(); for batch in groups.chunks(CONVERT_BATCH) { // A tile's content is the same at every coordinate sharing it: convert it at the first. - let jobs: Vec<_> = batch.iter().filter_map(|(t, cs)| cs.first().map(|c| (t, *c))).collect(); - let converted = fx::par_map(jobs, |(t, c)| convert_tile(surface, Some(t), kind, c)); - for ((t, cs), bytes) in batch.iter().zip(&converted) { - put(t, cs, bytes); + let jobs: Vec<_> = batch.iter().filter_map(|(t, cs)| cs.first().map(|c| (t, *c))).flat_map(|(t, c)| (0..bands).map(move |b| (t, c, b))).collect(); + let parts = fx::par_map(jobs, |(t, c, b)| { + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + if bands == 1 { + return convert_tile(surface, Some(t), kind, c); + } + let r = c.rect(); + let y0 = r.y0 + b * MANAGED_BAND_ROWS; + convert_rows(surface, Some(t), kind, Rect::new(r.x0, y0, r.x1, y0 + MANAGED_BAND_ROWS)) + }) + }); + for ((t, cs), bytes) in batch.iter().zip(parts.chunks(bands as usize)) { + // One part (every format but CMYK and Lab) goes through as is: no copy per tile. + match bytes { + [one] => put(t, cs, one), + _ => put(t, cs, &bytes.concat()), + } } } groups.len() } +/// Rows per parallel band of a colour-managed (CMYK, Lab) tile conversion. +const MANAGED_BAND_ROWS: i32 = 16; + +/// Whether `fmt` pixels reach `kind` texels through a per-pixel colour transform (CMYK, Lab). +fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool { + use photocraft_color::ColorMode; + matches!(kind, TexKind::Rgba8 | TexKind::Rgba16F) && matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) +} + /// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere. fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: TileCoord) -> Vec { if let (Some(t), TexKind::Rgba8Direct | TexKind::R8Direct) = (tile, kind) { return t.bytes().to_vec(); } - let n = (TILE_SIZE * TILE_SIZE) as usize; let fmt = surface.format(); - let ch = fmt.channels(); if let (Some(t), TexKind::Rgba16F) = (tile, kind) && let Some(out) = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())) { return out; } + convert_rows(surface, tile, kind, c.rect()) +} + +/// [`convert_tile`]'s general path for `rect` (rows of one tile): each pixel through +/// `photocraft_raster::to_rgba`. A run of identical pixels (the transparent or flat areas around a +/// brush dab) is converted once: the same samples give the same texels. +fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect: Rect) -> Vec { + let n = rect.width() as usize * rect.height() as usize; + let fmt = surface.format(); + let ch = fmt.channels(); let raw: Vec = match tile { - Some(_) => surface.read_region(c.rect()), + Some(_) => surface.read_region(rect), None => surface.default_pixel().repeat(n), }; let mut out = Vec::with_capacity(n * kind.bytes_per_pixel()); - for px in raw.chunks_exact(ch) { + // The previous pixel and its colour. + let mut last: Option<(usize, [f32; 4])> = None; + for (i, px) in raw.chunks_exact(ch).enumerate() { + let mut rgba = || -> [f32; 4] { + if let Some((j, v)) = last + && raw.get(j * ch..j * ch + ch).is_some_and(|p| p.iter().zip(px).all(|(a, b)| a.to_bits() == b.to_bits())) + { + return v; + } + let v = photocraft_raster::to_rgba(&fmt, px); + last = Some((i, v)); + v + }; match kind { TexKind::Rgba8Direct | TexKind::Rgba8 => { - let v = photocraft_raster::to_rgba(&fmt, px); + let v = rgba(); out.extend(v.map(|x| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); } TexKind::Rgba16F => { - let v = photocraft_raster::to_rgba(&fmt, px); + let v = rgba(); for x in v { out.extend(f32_to_f16(x).to_le_bytes()); } } TexKind::R8Direct => out.push((px[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8), + TexKind::R16F => out.extend(f32_to_f16(px[0]).to_le_bytes()), TexKind::R32F => out.extend(px[0].to_le_bytes()), } } @@ -2440,6 +2867,120 @@ pub fn render_to_vec_stats( mod tests { use super::*; + /// A run joins `LayerBlend` passes that only feed each other, at most `BATCH` of them, and + /// never ends in the slot it reads from. + fn check_runs(p: &Plan<'_>) -> Vec { + let runs = batch_runs(p); + assert_eq!(runs.len(), p.passes.len()); + for (i, &k) in runs.iter().enumerate() { + assert!((1..=BATCH).contains(&k), "pass {i}: run {k}"); + if k > 1 { + assert_ne!(Some(p.passes[i + k - 1].dst), p.passes[i].a, "run at {i} renders into its input"); + for j in i + 1..i + k { + assert_eq!(p.passes[j].kernel, Kernel::LayerBlend); + assert_eq!(p.passes[j].a, Some(p.passes[j - 1].dst), "run at {i}: pass {j} doesn't read the one before"); + assert!(p.passes[j].mask.is_none() && p.passes[j].mode != photocraft_color::BlendMode::Dissolve, "run at {i}: pass {j}"); + } + } + } + runs + } + + #[test] + fn plain_layers_batch_and_runs_stop_where_a_pass_must_stand_alone() { + use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerMask}; + let mut d = Document::with_background("t", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + for i in 0..12 { + let mut l = Layer::raster(format!("l{i}"), d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[0.2, 0.4, 0.6, 0.5]); + d.layers.push(l); + } + let p = plan::plan(&d).unwrap(); + let runs = check_runs(&p); + assert!(runs.iter().any(|k| *k >= 3), "{runs:?}"); + // The cache resumes only where a run starts, so a cached render draws the passes above + // its checkpoint in the same runs as an uncached one; checkpoints inside a run are + // skipped, but a stack of plain layers keeps some to resume at. + let starts = run_starts(&runs); + assert_eq!(starts.len(), runs.len() + 1); + assert!(starts[0] && starts[runs.len()]); + let (mut at, mut i) = (Vec::new(), 0); + while i < runs.len() { + at.push(i); + i += runs[i]; + } + assert_eq!(starts.iter().enumerate().filter(|(_, s)| **s).map(|(i, _)| i).collect::>(), [at, vec![runs.len()]].concat()); + let resumable = p.checkpoints.iter().filter(|c| c.pass_end > 1 && starts[c.pass_end]).count(); + assert!(resumable >= 2 && resumable < p.checkpoints.len(), "{resumable} of {:?}", p.checkpoints); + // A masked or Dissolve layer, or a clipped one, never joins a run as an extra layer. + d.layers[3].mask = Some(LayerMask::hide_all()); + d.layers[6].blend = BlendMode::Dissolve; + d.layers[9].clipped = true; + check_runs(&plan::plan(&d).unwrap()); + // Nothing to batch: a lone layer. + d.layers.truncate(1); + assert_eq!(check_runs(&plan::plan(&d).unwrap()), vec![1; plan::plan(&d).unwrap().passes.len()]); + } + + #[test] + fn batch_records_hold_three_extra_layers_in_the_spare_words() { + let lead = plan::Pass::new(Kernel::LayerBlend, 0); + let mut x = plan::Pass::new(Kernel::LayerBlend, 1); + x.opacity = 0.25; + x.fill = 0.5; + x.color = [0.1, 0.2, 0.3, 0.4]; + let w = batch_op_words(&lead, None, None, &[(&x, Some([1, 2, 3, 4])), (&x, None), (&x, None), (&x, None)]); + assert_eq!(w.len() as u64, OP_UNIFORM); + let word = |i: usize| <[u8; 4]>::try_from(&w[i * 4..i * 4 + 4]).unwrap(); + let f = |i: usize| f32::from_le_bytes(word(i)); + // At most three extras; the first's geometry, colour and parameters in p0, p1, p2. + assert_eq!(i32::from_le_bytes(word(1)), 3); + assert_eq!([f(20), f(21), f(22), f(23)], [1.0, 2.0, 3.0, 4.0]); + assert_eq!([f(24), f(25), f(26), f(27)], [0.1, 0.2, 0.3, 0.4]); + assert_eq!([f(28), f(29), f(31)], [0.25, 0.5, 1.0]); + // The third's parameters in q4; one without pixels says so. + assert_eq!([f(60), f(61), f(63)], [0.25, 0.5, 0.0]); + // Other passes leave the batch words zero. + let plain = op_words(&x, None, None, None); + assert!(plain[44 * 4..].iter().all(|b| *b == 0)); + } + + #[test] + fn runs_of_normal_layers_draw_with_normal_code_alone() { + use photocraft_color::BlendMode; + let pass = |mode: BlendMode, flags: u32| { + let mut p = plan::Pass::new(Kernel::LayerBlend, 0); + p.mode = mode; + p.flags = flags; + p + }; + let normal = pass(BlendMode::Normal, plan::F_HDR); + assert_eq!(batch_kernel(&[normal.clone(), pass(BlendMode::Normal, 0)]), Kernel::LayerBatchNormal); + // Any other mode, text gamma or a Lab document anywhere in the run takes the full shader. + for other in + [pass(BlendMode::Multiply, 0), pass(BlendMode::Dissolve, 0), pass(BlendMode::Normal, plan::F_TEXT_GAMMA), pass(BlendMode::Normal, plan::F_LAB)] + { + assert_eq!(batch_kernel(&[normal.clone(), other.clone()]), Kernel::LayerBatch); + assert_eq!(batch_kernel(&[other, normal.clone()]), Kernel::LayerBatch); + } + } + + #[test] + fn layer_pages_are_admitted_by_evicting_least_recently_used_first() { + let pages = || vec![(5, 'c', 40), (1, 'a', 30), (3, 'b', 20)]; + // Fits already: nothing dropped. + assert!(lru_victims(pages(), 90, 10, 100).is_empty()); + // 90 held + 40 incoming over 100: the oldest page goes first, then the next. + assert_eq!(lru_victims(pages(), 90, 40, 100), vec!['a']); + assert_eq!(lru_victims(pages(), 90, 60, 100), vec!['a', 'b']); + // Out of memory: a zero budget drops every candidate, and an impossible request stops + // when none are left instead of looping or overflowing. + assert_eq!(lru_victims(pages(), 90, 0, 0), vec!['a', 'b', 'c']); + assert_eq!(lru_victims(pages(), u64::MAX, u64::MAX, 1), vec!['a', 'b', 'c']); + assert!(lru_victims(Vec::<(u64, char, u64)>::new(), 90, 40, 100).is_empty()); + } + #[test] fn uniform_buffer_sizes_are_checked_before_allocation() { assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536); @@ -2532,6 +3073,28 @@ mod tests { assert_eq!(op_words(&p, None, None, None).len() as u64, OP_UNIFORM); } + /// #6534: a 16-bit mask (16-bit documents, feathered or vector combined masks) costs two + /// bytes per pixel on the GPU, twice an 8-bit mask, not four; 32-bit masks stay R32Float. + #[test] + fn sixteen_bit_masks_upload_at_two_bytes_per_pixel() { + let gray = |sample| PixelFormat { sample, ..PixelFormat::GRAY8 }; + assert_eq!(TexKind::for_surface(Role::Mask, PixelFormat::GRAY8).bytes_per_pixel(), 1); + let k16 = TexKind::for_surface(Role::Mask, gray(SampleType::U16)); + assert_eq!(k16.bytes_per_pixel(), 2, "{k16:?}"); + assert_eq!(k16.format().block_copy_size(None), Some(2), "{k16:?}"); + let k32 = TexKind::for_surface(Role::Mask, gray(SampleType::F32)); + assert_eq!(k32.format(), wgpu::TextureFormat::R32Float); + // The texels hold the mask values (a half each), the default pixel included. + let mut s = Surface::with_default(gray(SampleType::U16), &[1.0]); + s.fill_rect(Rect::new(0, 0, 4, 1), &[0.25]); + let c = TileCoord::new(0, 0); + let bytes = convert_tile(&s, s.tile(c), k16, c); + assert_eq!(bytes.len(), (TILE_SIZE * TILE_SIZE) as usize * 2); + assert_eq!(bytes.get(..2), Some(f32_to_f16(0.25).to_le_bytes().as_slice())); + assert_eq!(bytes.get(8..10), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + assert_eq!(convert_tile(&s, None, k16, c).get(..2), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + } + /// #1774: a solid fill shares one tile across every coordinate; it is converted once, and /// every coordinate (shared or not) still gets exactly the bytes `convert_tile` gives it, at /// every depth and texture kind. @@ -2544,7 +3107,8 @@ mod tests { (PixelFormat::RGBA16, TexKind::Rgba16F), (PixelFormat::RGBA32F, TexKind::Rgba16F), (PixelFormat::GRAY8, TexKind::R8Direct), - (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R32F), + (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R16F), + (PixelFormat { sample: SampleType::F32, ..PixelFormat::GRAY8 }, TexKind::R32F), ] { let mut s = Surface::new(fmt); let px = [0.2, 0.4, 0.6, 1.0]; @@ -2620,6 +3184,78 @@ mod tests { assert!(rgb_gray32_to_f16(&PixelFormat { sample: SampleType::F32, ..PixelFormat::CMYKA8 }, &[]).is_none()); } + /// CMYK and Lab tiles convert in parallel bands of rows, each run of equal pixels once + /// (#5108): the texels are still exactly the per-pixel conversion's, at 8 and 16 bits, with + /// the built-in CMYK profile and with a document's own (which the worker threads enter). + #[test] + fn colour_managed_tiles_match_the_per_pixel_conversion() { + use photocraft_cms::synth::{CmykParams, cmyk_profile}; + use photocraft_color::ColorMode; + use photocraft_color::convert::{CmykSpace, with_cmyk_space}; + let icc = Arc::new( + cmyk_profile(&CmykParams { description: "GPU test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }) + .to_bytes(), + ); + let own = CmykSpace::for_profile(Some(&icc)); + assert!(own.is_some(), "the synthetic profile is not the built-in one"); + let area = Rect::new(0, 0, TILE_SIZE * 3, TILE_SIZE); + for (mode, sample, space) in [ + (ColorMode::Cmyk, SampleType::U8, None), + (ColorMode::Cmyk, SampleType::U16, None), + (ColorMode::Cmyk, SampleType::U8, own.clone()), + (ColorMode::Cmyk, SampleType::U16, own.clone()), + (ColorMode::Lab, SampleType::U8, None), + (ColorMode::Lab, SampleType::U16, None), + ] { + let fmt = PixelFormat { mode, sample, alpha: true }; + let kind = TexKind::for_surface(Role::Content, fmt); + assert!(colour_managed(&fmt, kind), "{fmt:?}"); + let ch = fmt.channels(); + let mut s = Surface::new(fmt); + // Tile 0: noise. Tile 1: transparent with a soft dab (runs of equal pixels). Tile 2: + // a flat colour with a few lines of noise. + let n = (TILE_SIZE * TILE_SIZE) as usize * ch; + let noise: Vec = (0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) as f32 / u32::MAX as f32).collect(); + s.write_region(Rect::new(0, 0, TILE_SIZE, TILE_SIZE), &noise); + let mut dab = [0.8, 0.6, 0.1, 0.05, 0.0]; + for r in (1..30).rev() { + dab[ch - 1] = 1.0 - r as f32 / 30.0; + s.fill_rect(Rect::new(TILE_SIZE + 100 - r, 90 - r, TILE_SIZE + 100 + r, 90 + r), &dab[..ch]); + } + s.fill_rect(Rect::new(TILE_SIZE * 2, 0, TILE_SIZE * 3, TILE_SIZE), &[0.3, 0.2, 0.7, 0.1, 1.0][..ch]); + s.write_region(Rect::new(TILE_SIZE * 2, 40, TILE_SIZE * 3, 43), &noise[..TILE_SIZE as usize * 3 * ch]); + let tiles: Vec<_> = area.tiles().filter_map(|c| s.tile(c).map(|t| (c, t.clone()))).collect(); + assert_eq!(tiles.len(), 3, "{fmt:?}"); + let mut seen = 0; + with_cmyk_space(space.as_ref(), || { + convert_tiles(&s, tiles, kind, |_, cs, bytes| { + for &c in cs { + // The old path: every pixel through `to_rgba` on this thread, in this scope. + let want: Vec = s + .read_region(c.rect()) + .chunks_exact(ch) + .flat_map(|px| { + let v = photocraft_raster::to_rgba(&fmt, px); + match kind { + TexKind::Rgba16F => v.iter().flat_map(|x| f32_to_f16(*x).to_le_bytes()).collect::>(), + _ => v.map(|x| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8).to_vec(), + } + }) + .collect(); + assert!( + bytes == want.as_slice(), + "{fmt:?} {c:?} with {}", + if space.is_some() { "the document's profile" } else { "the built-in profile" } + ); + assert_eq!(convert_tile(&s, s.tile(c), kind, c), want, "{fmt:?} {c:?}"); + seen += 1; + } + }); + }); + assert_eq!(seen, 3, "{fmt:?}"); + } + } + #[test] fn grid_rounds_out() { assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256)); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..815b55517d 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -34,6 +34,14 @@ pub enum Kernel { Content, Mask, Blend, + /// `Content` then `Blend` in one pass: a plain layer's pixels blended straight onto A. + LayerBlend, + /// A run of consecutive `LayerBlend` passes drawn as one (never planned: the executor draws + /// it in their place, see `batch_runs`). + LayerBatch, + /// `LayerBatch` of a run whose layers all blend Normal without text gamma, outside Lab + /// documents (never planned: the executor picks it, see `batch_kernel`). + LayerBatchNormal, Atop, Adjust, AdjMix, @@ -73,6 +81,9 @@ impl Kernel { Kernel::Content => "fs_content", Kernel::Mask => "fs_mask", Kernel::Blend => "fs_blend", + Kernel::LayerBlend => "fs_layerblend", + Kernel::LayerBatch => "fs_layerbatch", + Kernel::LayerBatchNormal => "fs_layerbatch_normal", Kernel::Atop => "fs_atop", Kernel::Adjust => "fs_adjust", Kernel::AdjMix => "fs_adjmix", @@ -111,10 +122,13 @@ impl Kernel { } /// Every kernel with a pipeline. - pub const DRAWN: [Kernel; 21] = [ + pub const DRAWN: [Kernel; 24] = [ Kernel::Content, Kernel::Mask, Kernel::Blend, + Kernel::LayerBlend, + Kernel::LayerBatch, + Kernel::LayerBatchNormal, Kernel::Atop, Kernel::Adjust, Kernel::AdjMix, @@ -395,6 +409,10 @@ pub const F_ADD_DIFF: u32 = 16384; pub const F_BLEND_IF: u32 = 524288; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// `FxMerge`: B is the layer's shape (its content); the layer is its colour at fill opacity +/// (`p0.x`) inside the shape, times the shape's alpha: `FxInit` `INIT_INSIDE` then +/// `INIT_ALPHA` in the merge, for a layer with no interior effects between them. +pub const F_LAYER_SHAPE: u32 = 1_048_576; /// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as /// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and @@ -764,26 +782,36 @@ impl<'a> Planner<'a> { } return Ok(backdrop); } - let start = self.passes.len(); - let mut content = self.content(layer)?; - for c in visible_clipped { - content = self.atop(c, content)?; - } - if let Some(clip) = clip { - // Everything that built the layer's content only matters inside its bounds. - for p in &mut self.passes[start..] { - if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { - p.clip = Some(clip); + // A layer with nothing clipped to it whose content is one Content pass is blended + // straight from its pixels (`Kernel::LayerBlend`): one pass instead of two, and no + // scratch chunk written and read back. + let mut p = if visible_clipped.is_empty() && single_content_pass(layer) { + let mut p = self.content_pass(layer)?; + p.kernel = Kernel::LayerBlend; + p + } else { + let start = self.passes.len(); + let mut content = self.content(layer)?; + for c in visible_clipped { + content = self.atop(c, content)?; + } + if let Some(clip) = clip { + // Everything that built the layer's content only matters inside its bounds. + for p in &mut self.passes[start..] { + if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { + p.clip = Some(clip); + } } } - } - let mut p = Pass::new(Kernel::Blend, 0); + let mut p = Pass::new(Kernel::Blend, 0); + p.b = Some(content); + p + }; p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop }); - p.b = Some(content); p.mode = layer.blend; p.opacity = layer.opacity; p.fill = layer.fill_opacity; - p.flags = gamma_flag(layer); + p.flags |= gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = clip; let merged = self.emit(p); @@ -804,6 +832,11 @@ impl<'a> Planner<'a> { /// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop); /// returns the new backdrop. Consumes both. fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot { + // A clip over the whole canvas left no pixel of `merged` uncomputed: it is the backdrop. + if clip.contains_rect(&self.cx.canvas) { + self.release(backdrop); + return merged; + } let dst = if self.refs[backdrop as usize] == 1 { backdrop } else { @@ -1129,6 +1162,25 @@ 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); + p.b = Some(lay_src); + p.c = Some(self.retain(backdrop)); + p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + p.opacity = layer.opacity; + p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer) | F_LAYER_SHAPE; + p.params[0][0] = layer.fill_opacity; + 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)); + } // 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 { @@ -1139,7 +1191,6 @@ impl<'a> Planner<'a> { }; // Blend Interior Effects as Group off: the layer merges in its mode first and the // interior effects paint over the result (`effects::premerges_layer`). - let premerge = photocraft_compose::effects::premerges_layer(layer); if premerge { let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(self.retain(w)); @@ -1278,7 +1329,9 @@ impl<'a> Planner<'a> { p.c = cover.map(|c| self.retain(c)); p.d = acc; let a = self.emit(p); - let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb); + // The stroke's mode rides along only for Dissolve, whose coverage is all or + // nothing in this pass as in the colour pass (#6995). + let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), st.common.blend, st.common.opacity, flags, clip, sb); p.adjust_kind = 1; p.map = Some(map(i, 0)); p.c = cover; @@ -1650,7 +1703,8 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { - p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; + let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); + p[0] = [*hue, sat, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; @@ -1706,9 +1760,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (13, p, Some(rows)) } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { - // Per-channel curves (compose::adjust::color_balance_channel), through the Levels kernel. - let tones = [*shadows, *midtones, *highlights]; - (7, p, Some(lut_rows(|ch, v| adjust::color_balance_channel(tones, *preserve_luminosity, ch, v), 3))) + // Per-channel curves (compose::adjust::BalanceCurve), through the Levels kernel. + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + (7, p, Some(lut_rows(|ch, v| curves[ch].eval(v), 3))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). @@ -1806,6 +1860,16 @@ pub fn mode_index(m: BlendMode) -> i32 { } } +/// Whether `Planner::content` draws `layer` as one `Planner::content_pass`: not a group's stack, +/// an adjustment, or a pattern fill painted in passes. +fn single_content_pass(layer: &Layer) -> bool { + match &layer.content { + LayerContent::Group(_) | LayerContent::Adjustment(_) => false, + LayerContent::Fill(f @ Fill::Pattern { .. }) => layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1946,15 +2010,15 @@ mod tests { d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format())); } let p = plan(&d).unwrap(); - // Empty layers draw nothing. - assert_eq!(p.passes.len(), 1 + 2); + // Empty layers draw nothing: the clear and the background's one fused pass. + assert_eq!(p.passes.len(), 1 + 1); for (i, l) in d.layers.iter_mut().skip(1).enumerate() { l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]); } let p = plan(&d).unwrap(); assert!(p.slots <= 3, "{} slots", p.slots); - // The background, then each small layer over its bounds and copied back. - assert_eq!(p.passes.len(), 1 + 2 + 10 * 3); + // The background, then each small layer blended over its bounds and copied back. + assert_eq!(p.passes.len(), 1 + 1 + 10 * 2); } #[test] @@ -1989,11 +2053,44 @@ mod tests { assert_eq!(p.fx.len(), 1); let m = photocraft_compose::effects::margin(&d.layers[1]); assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m))); - // The effect result lands back in the backdrop slot. + // The effect region covers the whole canvas: the merged result is the backdrop, no copy. + assert!(p.fx[0].region.contains_rect(&d.bounds())); let last = p.passes.last().unwrap(); - assert_eq!(last.kernel, Kernel::CopyRect); + assert_eq!(last.kernel, Kernel::FxMerge); assert_eq!(last.dst, p.root); assert!(p.slots <= 6, "{} slots", p.slots); + // On a larger canvas the result lands back in the backdrop slot over the region only. + let mut big = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); + big.layers.push(d.layers[1].clone()); + let p = plan(&big).unwrap(); + assert!(!p.fx[0].region.contains_rect(&big.bounds())); + let last = p.passes.last().unwrap(); + assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(p.fx[0].region))); + } + + #[test] + fn exterior_effects_merge_from_the_shape() { + // A drop shadow alone: the layer's pixels, the shadow, the merge. With an interior effect + // the layer is built in passes of its own (colour inside the shape, the overlay, alpha). + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut l = Layer::raster("fx", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 0.5]); + l.fill_opacity = 0.7; + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + d.layers.push(l); + let p = plan(&d).unwrap(); + let fx: Vec = p.passes.iter().skip(2).map(|p| p.kernel).collect(); + assert_eq!(fx, [Kernel::Content, Kernel::FxPaint, Kernel::FxMerge], "{:?}", p.passes); + let merge = p.passes.last().unwrap(); + assert!(merge.flags & F_LAYER_SHAPE != 0); + assert_eq!(merge.params[0][0], 0.7); + d.layers[1] + .effects + .items + .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE }); + let p = plan(&d).unwrap(); + assert!(p.passes.iter().all(|p| p.flags & F_LAYER_SHAPE == 0)); + assert!(p.passes.iter().any(|p| p.kernel == Kernel::FxInit)); } #[test] diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index cf53a62850..31406c6449 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; use photocraft_doc::{Document, Layer}; use photocraft_geom::Rect; -use crate::plan::{Checkpoint, Plan}; +use crate::plan::Checkpoint; pub(crate) const MAX_BYTES: u64 = 512 << 20; @@ -104,8 +104,10 @@ impl Prefix { self.context.as_ref().map(|d| d.id) } - /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at. - pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option { + /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at, one + /// of `checkpoints` (the plan's that a batch run starts at). A chunk held at any other point + /// is dropped too: resuming there would group the layers above it differently. + pub(crate) fn prepare(&mut self, doc: &Document, checkpoints: &[Checkpoint]) -> Option { if self.budget == 0 { return None; } @@ -125,7 +127,7 @@ impl Prefix { if same_context { let mut freed = 0u64; self.chunks.retain(|_, c| { - let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint); + let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && checkpoints.contains(&c.checkpoint); if !keep { freed = freed.saturating_add(c.bytes); } @@ -135,12 +137,12 @@ impl Prefix { } else { self.clear(); } - let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); + let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && checkpoints.contains(c)); let checkpoint = kept.or_else(|| match changed { // Just below the edit; nothing when it is at the bottom. - Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), + Some(changed) if same_context => checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), _ => { - let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1); + let mut useful = checkpoints.iter().rev().filter(|c| c.pass_end > 1); let last = useful.next().copied(); useful.next().copied().or(last) } diff --git a/crates/gpu/src/timing.rs b/crates/gpu/src/timing.rs new file mode 100644 index 0000000000..e79548d84c --- /dev/null +++ b/crates/gpu/src/timing.rs @@ -0,0 +1,308 @@ +//! GPU execution time of a composite (#7186), measured with wgpu timestamp queries. +//! +//! `GpuCanvas::refresh` reports `composite_ms`, the CPU cost of *encoding and submitting* the +//! composite. That is not the cost of running it: at 24 MP the encode costs ~7.7 ms and the GPU +//! then spends ~171 ms finishing the work, so every consumer of `composite_ms` under-reports a +//! GPU-bound canvas by more than an order of magnitude and cannot see a regression that moves +//! work onto the GPU. +//! +//! Waiting for the GPU to report the true number would serialise the two processors and make the +//! app slower, so this measures instead of waiting: a timestamp is written on the GPU timeline at +//! the start and end of every command encoder a refresh submits, and the values are read back +//! asynchronously. The sum of the spans is the time the GPU spent executing that refresh, with +//! the CPU encoding gaps between submissions excluded. Nothing blocks; the numbers simply arrive +//! a frame or two later, so each measurement carries the pixel count of the refresh it belongs to +//! and is never attributed to whichever refresh happens to be in flight when it lands. +//! +//! **What it does not count.** The spans run between bottom-of-pipe timestamps inside the +//! refresh's own command buffers. Two kinds of GPU work fall outside them: queue-level uploads +//! (`Queue::write_texture`), which wgpu executes in its own command buffer before ours, and +//! whatever the GPU still does after the final timestamp retires — the end-of-batch writeback of +//! the render targets. Measured against a waited-for wall clock on Intel UHD P630 / Mesa, with +//! no uploads outstanding, the uncounted remainder is **~5 % of the reported time**, and it is +//! proportional to the composite rather than a fixed submit latency (1.0 ms at 6 MP, 4.5 ms at +//! 24 MP, 7.6 ms at 42.6 MP, against 21 / 83 / 158 ms reported). So the number is a slight +//! under-report of the GPU's total contribution, by a few per cent rather than the ~22x of the +//! encode-and-submit time it replaces. +//! +//! **What it costs.** Nothing measurable. The same refresh on the same device with +//! `PHOTOCRAFT_NO_GPU_TIMING` set waits 88.1 ms against 87.9 ms measured — a difference of +//! −0.2 ms on an ~88 ms refresh, i.e. inside the noise of a shared host. Per refresh the +//! queries add two timestamp writes (two more per flush), one resolve and one copy of at most +//! 1 KiB, a `map_async` and an `Arc` allocation; the query set and both buffers are allocated +//! once per compositor and are size-independent, so a resize reallocates nothing. +//! +//! The measurement is *optional by construction*. Without `TIMESTAMP_QUERY` and +//! `TIMESTAMP_QUERY_INSIDE_ENCODERS` on the device (older drivers, most browsers, the Noop +//! backend), [`GpuTimer::new`] returns `None` and callers report no GPU time at all rather than a +//! plausible wrong number — reporting a plausible wrong number is the failure this fixes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// The GPU execution time of one composite, with the refresh it measured. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuTiming { + /// Time the GPU spent executing the refresh's command buffers (ms). Excludes queue-level + /// uploads, which run in wgpu's own command buffer ahead of ours, and the end-of-batch work + /// after the last timestamp: see the module doc for the size of that (~5 % here, more on a + /// refresh that uploads). + pub ms: f64, + /// Pixels that refresh composited (so a late measurement stays interpretable). + pub px: u64, + /// Command buffers the refresh submitted (1 unless it flushed). + pub spans: u32, +} + +/// Device features [`GpuTimer`] needs. +pub const FEATURES: wgpu::Features = wgpu::Features::TIMESTAMP_QUERY.union(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS); + +/// Spans one measurement may hold. A refresh submits once per flush; a 200 MP document flushes a +/// handful of times, so this is far above anything real. A measurement that would exceed it is +/// dropped rather than truncated (a truncated sum is exactly the under-report being fixed). +const SPANS: u32 = 64; +/// Any span total above this is a wrapped or garbage timestamp, not a composite. +const SANE_MS: f64 = 600_000.0; + +/// Readback state of the buffer holding the resolved timestamps. +const UNMAPPED: u8 = 0; +const MAPPED: u8 = 1; +const FAILED: u8 = 2; + +/// Timestamp queries for one compositor, reused for every refresh. +/// +/// At most one measurement is in flight: while the last one is still being read back a refresh +/// goes unmeasured, which costs a sample rather than correctness. +pub struct GpuTimer { + set: wgpu::QuerySet, + /// Resolved timestamps (`QUERY_RESOLVE | COPY_SRC`). + resolve: wgpu::Buffer, + /// Host-visible copy of `resolve` (`MAP_READ | COPY_DST`). + read: wgpu::Buffer, + /// Nanoseconds per timestamp tick, as the queue reports it. + period_ns: f32, + /// Timestamps written into the current recording. + written: u32, + /// Whether a span is open (a start timestamp waiting for its end). + open: bool, + /// Pixels of the recording in progress. + px: u64, + /// The recording overflowed [`SPANS`]: finish without resolving. + overflowed: bool, + /// The recording in progress has already had command buffers submitted (it flushed), so its + /// query slots may still be in use by the GPU. + flushed: bool, + /// A recording was abandoned after it had already submitted timestamps. Writing a query + /// again resets that slot (wgpu-core resets per write), and resetting one an in-flight + /// submission is still writing is undefined, with no way to learn when it has drained. + /// That only happens when a refresh fails after a flush, which on this path means the + /// device faulted and the canvas is about to drop to the CPU compositor anyway, so + /// measuring simply stops: no number, which is the contract everywhere else here. + stopped: bool, + /// Timestamps and pixels of the measurement being read back, and its readback flag. + in_flight: Option<(u32, u64, Arc)>, + /// [`Self::finish`] recorded a readback that [`Self::submitted`] has yet to map. Without + /// this, a refresh that starts before the last measurement lands would map the readback + /// buffer a second time while the first map is still pending, which wgpu rejects. + unmapped: bool, + /// The most recent completed measurement, until a caller takes it. + ready: Option, +} + +impl std::fmt::Debug for GpuTimer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GpuTimer").field("recording", &self.open).field("in_flight", &self.in_flight.is_some()).field("ready", &self.ready).finish() + } +} + +impl GpuTimer { + /// A timer for `device`, or `None` when it has no timestamp queries (the caller then reports + /// no GPU time, never an estimate). + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option { + if !device.features().contains(FEATURES) { + return None; + } + let period_ns = queue.get_timestamp_period(); + if !period_ns.is_finite() || period_ns <= 0.0 { + return None; + } + let bytes = u64::from(SPANS) * 2 * 8; + Some(Self { + set: device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("pc_compose_time"), ty: wgpu::QueryType::Timestamp, count: SPANS * 2 }), + resolve: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_resolve"), + size: bytes, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + read: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_read"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + period_ns, + written: 0, + open: false, + px: 0, + overflowed: false, + flushed: false, + stopped: false, + in_flight: None, + unmapped: false, + ready: None, + }) + } + + /// Start measuring a refresh of `px` pixels, opening its first span in `encoder`. Does + /// nothing while a previous measurement is still being read back (that refresh goes + /// unmeasured) — call [`Self::collect`] first to give it the chance to land. + pub fn start(&mut self, encoder: &mut wgpu::CommandEncoder, px: u64) { + if self.stopped || self.in_flight.is_some() || self.open { + return; + } + self.written = 0; + self.overflowed = false; + self.flushed = false; + self.px = px; + self.open = true; + self.stamp(encoder); + } + + /// Close the span in `done` and open the next in `next`: one refresh that submits several + /// command buffers is one measurement, summed over them. + pub fn split(&mut self, done: &mut wgpu::CommandEncoder, next: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(done); + self.stamp(next); + self.flushed = true; + } + + /// Close the last span and record the resolve and readback copy into `encoder`, which the + /// caller is about to submit. Call [`Self::submitted`] once it has. + pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(encoder); + self.open = false; + let count = self.written; + if self.overflowed || count < 2 || !count.is_multiple_of(2) { + self.abandon(); + return; + } + let bytes = u64::from(count) * 8; + encoder.resolve_query_set(&self.set, 0..count, &self.resolve, 0); + encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes); + self.in_flight = Some((count, self.px, Arc::new(AtomicU8::new(UNMAPPED)))); + self.unmapped = true; + self.written = 0; + // This recording is over: its slots are protected by `in_flight` until the readback + // lands, not by `flushed`. Leaving `flushed` set would let a later refresh that + // recorded nothing (because `start` found the readback still in flight) stop measuring + // for good when it failed, on the strength of this recording's flush. + self.flushed = false; + } + + /// Map the readback buffer after the caller submitted the encoder [`Self::finish`] wrote + /// into. The map completes on a later device poll; nothing blocks here. + pub fn submitted(&mut self) { + if !self.unmapped { + return; + } + self.unmapped = false; + let Some((count, _, flag)) = &self.in_flight else { return }; + let (bytes, flag) = (u64::from(*count) * 8, flag.clone()); + self.read.slice(0..bytes).map_async(wgpu::MapMode::Read, move |r| { + flag.store(if r.is_ok() { MAPPED } else { FAILED }, Ordering::Release); + }); + } + + /// Abandon the recording in progress (the refresh failed part way, so its spans mean + /// nothing). + pub fn cancel(&mut self) { + self.open = false; + self.abandon(); + } + + /// Drop the recording in progress. Once its timestamps have reached the GPU its slots can't + /// be reused safely, so measuring stops for good rather than resetting a live query. + fn abandon(&mut self) { + self.stopped |= self.flushed; + self.written = 0; + self.overflowed = false; + self.flushed = false; + } + + /// Whether a measurement is being read back (so this refresh won't be measured). + pub fn busy(&self) -> bool { + self.in_flight.is_some() + } + + /// Read back a finished measurement if the map completed, and return whatever is ready. + /// Cheap and non-blocking; callers poll it per refresh or per frame. + pub fn collect(&mut self) -> Option { + if let Some((count, px, flag)) = &self.in_flight { + match flag.load(Ordering::Acquire) { + MAPPED => { + let (count, px) = (*count, *px); + self.ready = self.value(count, px); + self.read.unmap(); + self.in_flight = None; + } + FAILED => { + self.in_flight = None; + } + _ => {} + } + } + self.ready + } + + /// Put a measurement back after a caller took it but could not use it (its refresh failed), + /// so the sample is reported by the next one instead of being lost on the fallback path. + /// A measurement that has landed since wins: it is the newer of the two. + pub fn restore(&mut self, t: GpuTiming) { + self.ready.get_or_insert(t); + } + + /// Take the most recent completed measurement, leaving none behind, so a stale number is + /// never reported twice as if it were new. + pub fn take(&mut self) -> Option { + self.collect(); + self.ready.take() + } + + /// Sum the resolved spans of a mapped readback. + fn value(&self, count: u32, px: u64) -> Option { + let bytes = u64::from(count) * 8; + let view = self.read.slice(0..bytes).get_mapped_range().ok()?; + let mut ticks = 0u64; + for pair in view.as_chunks::<16>().0 { + let (a, b) = pair.split_at(8); + let start = u64::from_le_bytes(a.try_into().ok()?); + let end = u64::from_le_bytes(b.try_into().ok()?); + // A driver that reset or wrapped its timer gives an end before its start: the whole + // measurement is then unusable, and no number is better than a wrong one. + if end < start { + return None; + } + ticks += end - start; + } + drop(view); + let ms = ticks as f64 * f64::from(self.period_ns) / 1.0e6; + (ms.is_finite() && ms < SANE_MS).then_some(GpuTiming { ms, px, spans: count / 2 }) + } + + /// Write one timestamp, noting an overflow instead of wrapping round the query set. + fn stamp(&mut self, encoder: &mut wgpu::CommandEncoder) { + if self.written >= SPANS * 2 { + self.overflowed = true; + return; + } + encoder.write_timestamp(&self.set, self.written); + self.written += 1; + } +} diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/it/device_loss.rs similarity index 92% rename from crates/gpu/tests/device_loss.rs rename to crates/gpu/tests/it/device_loss.rs index 689a787280..666e434da4 100644 --- a/crates/gpu/tests/device_loss.rs +++ b/crates/gpu/tests/it/device_loss.rs @@ -8,8 +8,7 @@ use photocraft_geom::{Rect, Size}; use photocraft_gpu::{Compositor, DeviceHealth, Fault, render_to_vec}; use photocraft_raster::Surface; -/// Concurrent wgpu instances in one process segfault on some drivers (see `parity.rs`). -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { let instance = wgpu::Instance::default(); @@ -82,6 +81,28 @@ fn health_wait_on_a_destroyed_device_is_safe() { assert!(!health.wait(&device, None)); } +/// #3438: only wgpu reporting the device lost counts as a real loss. A fault recorded by hand (a +/// simulated loss, or an uncaptured error that names one) leaves the device usable, and +/// [`DeviceHealth::probe`] keeps polling it so a later real loss is still noticed. +#[test] +fn only_wgpu_reports_a_real_device_loss() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((_adapter, device, _queue)) = device() else { return }; + let health = DeviceHealth::watch(&device); + health.probe(&device); + assert!(health.is_ok() && !health.device_lost()); + health.mark(Fault::Lost("simulated".into())); + health.probe(&device); + assert!(!health.is_ok()); + assert!(!health.device_lost(), "a recorded fault is not a real loss"); + device.destroy(); + health.probe(&device); + assert!(health.device_lost(), "a destroyed device is lost: {:?}", health.fault()); + // Once lost, probing is a no-op rather than another caught panic. + health.probe(&device); + assert!(health.device_lost()); +} + fn assert_unwatched_destroyed_device(encode_only: bool) { let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = device() else { return }; diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs new file mode 100644 index 0000000000..f71972c5b9 --- /dev/null +++ b/crates/gpu/tests/it/main.rs @@ -0,0 +1,11 @@ +//! The crate's integration tests, linked into one test binary. + +/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so every test that +/// creates a device takes this lock and holds it until the device is dropped. +static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +mod device_loss; +mod parity; +mod timing; +mod uniform_limits; +mod vector_mask; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/it/parity.rs similarity index 84% rename from crates/gpu/tests/parity.rs rename to crates/gpu/tests/it/parity.rs index 84d3d0e937..464ad1f1a2 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask}; -use photocraft_geom::{Rect, Size}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_gpu::{Compositor, render_to_vec}; /// One 8-bit step, plus a few f32 ulps: two results exactly one step apart can differ by a hair @@ -12,9 +12,7 @@ use photocraft_gpu::{Compositor, render_to_vec}; /// lands on that side at the Hue/Saturation pixel in `adjustment_layers`). const TOL: f32 = 1.0 / 255.0 + 4.0 * f32::EPSILON; -/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests -/// take this lock and hold it until their device is dropped. -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; struct Gpu { device: wgpu::Device, @@ -78,32 +76,49 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { } } +/// The default adapter, or the one wgpu's `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choose: e.g. +/// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc` tests D3D12 with FXC, the app's default on Windows, +/// where the default adapter may be Vulkan. A backend asked for that can't run the tests fails +/// them rather than skipping. fn gpu() -> Option { let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let instance = wgpu::Instance::default(); + let required = std::env::var_os("WGPU_BACKEND").is_some(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) { Ok(a) => a, Err(e) => { + assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}"); eprintln!("skipping GPU parity tests: no adapter ({e})"); return None; } }; - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; - eprintln!("GPU parity backend: {:?}", adapter.get_info().backend); - // Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software - // rasterizers) use the CPU compositor in the app, so there is nothing to compare there. - if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float { - eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float"); - return None; - } - let comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) { - Ok(c) => c, + let info = adapter.get_info(); + let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) { + Ok(d) => d, Err(e) => { - eprintln!("skipping GPU parity tests: {e}"); + assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name); + eprintln!("skipping GPU parity tests: no device ({e})"); return None; } }; - let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?; + eprintln!("GPU parity backend: {:?} ({}, {:?})", info.backend, info.name, info.device_type); + // Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software + // rasterizers) use the CPU compositor in the app, so there is nothing to compare there. + if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float { + assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name); + eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float"); + return None; + } + let compositor = || match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) { + Ok(c) => Some(c), + Err(e) => { + assert!(!required, "WGPU_BACKEND is set but {} can't build the compositor: {e}", info.name); + eprintln!("skipping GPU parity tests: {e}"); + None + } + }; + let comp = compositor()?; + let mut paged = compositor()?; paged.set_texture_limit(PAGED_LIMIT); Some(Gpu { device, queue, comp, paged, _lock: lock }) } @@ -151,6 +166,14 @@ fn mask(rect: Rect, seed: u32, default: f32) -> LayerMask { m } +/// `l` with a mask that reveals everything, so the executor draws it in a pass of its own rather +/// than batched with the layer below: prefix tests resume at any layer (only where a batch run +/// starts, `batch_runs`). +fn alone(mut l: Layer) -> Layer { + l.mask = Some(LayerMask::reveal_all()); + l +} + fn base_doc(w: u32, h: u32) -> Document { let mut d = Document::new("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8); d.layers.push(noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, w, h), 1, 0.3)); @@ -195,6 +218,29 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) { diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); } +/// Growing a layer's tile footprint keeps the old texels in the overlap (`keep_overlap`) +/// while the same update rewrites tiles inside that overlap (#6243): whatever submission +/// shape the copy takes, the kept overlap must never overwrite tiles the same update +/// rewrote — the fresh texels win. Every step grows by one tile and rewrites everything +/// with per-step noise, on the whole-texture and the paged compositor alike, and each +/// render matches the CPU oracle. +#[test] +fn tile_growth_rewrite_keeps_fresh_texels_in_the_overlap() { + let Some(mut g) = gpu() else { return }; + let tile = TILE_SIZE as u32; + let steps = 4; + let mut doc = Document::new("tile growth", Size::new(steps * tile, tile), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::raster("grow", PixelFormat::RGBA8)); + for step in 1..=steps { + // Wider by one tile than at the last render, so the resident's region grows and every + // tile inside the kept overlap changes: stale texels cannot masquerade as correct ones. + let w = step * tile; + let data: Vec = (0..w * tile * 4).map(|i| rnd(1000 + step, i)).collect(); + doc.layers[0].surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, tile), &data); + check(&mut g, &doc, &format!("tile growth step {step}/{steps}")); + } +} + /// Closing a document frees what the compositor holds for it, however its canvas entry went /// (#4529): `retain_docs` drops the resident pages, effect maps and lower-stack composite of /// documents that aren't open, and keeps the open ones. @@ -232,7 +278,7 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut doc = Document::new("reuse", Size::new(300, 70), ColorMode::Rgb, depth); for seed in 1..=4 { - doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + doc.layers.push(alone(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2))); } check(&mut g, &doc, "populate prefix"); for (opacity, fill, blend, visible) in [ @@ -270,22 +316,31 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { /// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes /// from the lower cached prefix and moves the cache up to just below the copy, so the undo and /// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every -/// depth. +/// depth, also when plain layers are drawn several to a pass and the cache can only resume where +/// a batch starts. #[test] fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { let Some(mut g) = gpu() else { return }; let mut uncached = Compositor::new(&g.device); uncached.set_composite_cache_budget(0); - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (depth, batched) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|d| [(d, false), (d, true)]) { let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth); for seed in 1..=6 { - doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + let l = noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2); + doc.layers.push(if batched { l } else { alone(l) }); } let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats { let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap(); - let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); - assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly"); + let (cold, cold_stats) = photocraft_gpu::render_to_vec_stats(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + assert_eq!(cached, cold, "{depth:?} batched {batched} {what}: reuse preserves the GPU accumulator exactly"); + // Batched and single-layer draws needn't give the same bits on every backend (FXC + // on D3D12): a render that captures the prefix groups the layers as an uncached one + // does, wherever its checkpoint is. + if stats.prefix_hits == 0 { + assert_eq!(stats.layers_batched, cold_stats.layers_batched, "{depth:?} {what}: capturing changed the batches"); + } + assert_eq!(cold_stats.layers_batched > 0, batched, "{depth:?} {what}"); stats }; exact(&mut g, &doc, "populate"); @@ -296,15 +351,17 @@ fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { let copy = doc.layers[4].duplicate(); doc.layers.insert(5, copy); let first = exact(&mut g, &doc, "duplicate"); - assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); let undo = exact(&mut g, &low, "undo"); - assert!(undo.prefix_hits > 0, "{depth:?}"); - assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + assert!(undo.prefix_hits > 0, "{depth:?} batched {batched}"); + if !batched { + assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); + assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + } let redo = exact(&mut g, &doc, "redo"); - assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}"); + assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?} batched {batched}"); // An edit below the moved cache still invalidates it. doc.layers[2].opacity = 0.4; - assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}"); + assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?} batched {batched}"); } } @@ -319,7 +376,7 @@ fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { uncached.set_composite_cache_budget(0); let mut doc = base_doc(600, 300); for seed in 2..=5 { - doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)); + doc.layers.push(alone(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2))); } let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats { let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap(); @@ -435,8 +492,8 @@ fn disjoint_partial_damages_admit_whole_chunks_not_damage_rects() { fn prefix_partial_damage_context_occlusion_and_budget_are_conservative() { let Some(mut g) = gpu() else { return }; let mut doc = base_doc(600, 300); - doc.layers.push(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2)); - doc.layers.push(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2)); + doc.layers.push(alone(noise_layer("middle", PixelFormat::RGBA8, doc.bounds(), 2, 0.2))); + doc.layers.push(alone(noise_layer("top", PixelFormat::RGBA8, doc.bounds(), 3, 0.2))); check(&mut g, &doc, "full prefix"); // A partial rectangle inside an already-covered whole chunk is served as a sub-rect copy — // after a full render every in-bounds rectangle is covered, which is the point of whole @@ -652,6 +709,29 @@ 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 } } +/// 32-bit Hue/Saturation on colours at and above HSL lightness 1 and with a negative channel, +/// where the HSL saturation divides by zero: the GPU scales the chroma as the CPU does +/// (compose::adjust::hue_saturation_in), finite and unclipped. +#[test] +fn hdr_hue_saturation_bright_colours() { + let Some(mut g) = gpu() else { return }; + let mut d = Document::new("t", Size::new(4, 1), ColorMode::Rgb, SampleType::F32); + let mut bg = Layer::raster("bg", d.pixel_format()); + let px = [2.0, 0.0, 0.0, 1.0, 0.25, -0.25, 0.0, 1.0, 1.1, 0.9, 1.0, 1.0, 0.55, 0.45, 0.5, 1.0]; + bg.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 4, 1), &px); + d.layers.push(bg); + for (hue, saturation) in [(40.0, 0.0), (0.0, 25.0), (0.0, -25.0), (40.0, 75.0)] { + let hs = Adjustment::HueSaturation { hue, saturation, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("hue {hue} saturation {saturation} on bright colours")); + d.layers.pop(); + } + // A PSD can store a Colorize saturation beyond 100: clamped before the 32-bit scaling on both. + let hs = Adjustment::HueSaturation { hue: 200.0, saturation: 150.0, lightness: 50.0, colorize: true, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, "colorize saturation 150"); +} + #[test] fn adjustment_layers() { let Some(mut g) = gpu() else { return }; @@ -671,6 +751,14 @@ fn adjustment_layers() { let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true }; d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf))); check(&mut g, &d, &format!("photo filter {depth:?}")); + // Colorize: the saturation scaled by 1 - |lightness| at 32 bits (compose::adjust::colorize_saturation). + let hs = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("colorize {depth:?}")); + // Hue/Saturation: at 32 bits the raised saturation isn't clipped. + let hs = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs2", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("hue/saturation {depth:?}")); // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); check(&mut g, &d, &format!("threshold {depth:?}")); @@ -735,6 +823,19 @@ fn groups_masks_and_clipping() { d.layers.extend([base, c1, c2]); check(&mut g, &d, &format!("clipping hidden={hidden}")); } + // #3052/#4921: a clipped Dissolve layer retains opacity/fill binary coverage + // across the partially covered clipping base; the GPU must match the CPU. + for (opacity, fill) in [(0.4, 1.0), (0.8, 0.5)] { + let mut d = base_doc(56, 44); + let base = noise_layer("base", PixelFormat::RGBA8, Rect::new(5, 6, 48, 40), 45, 0.0); + let mut clip = noise_layer("dissolve clip", PixelFormat::RGBA8, d.bounds(), 46, 0.0); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = opacity; + clip.fill_opacity = fill; + d.layers.extend([base, clip]); + check(&mut g, &d, "clipped Dissolve opacity/fill CPU/GPU parity"); + } // Clipped group base (isolated) with a clipped layer. let mut d = base_doc(56, 44); let mut gb = Layer::group("gb", vec![child(18, BlendMode::Normal)]); @@ -855,7 +956,13 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() { let paint = FxPaint::Gradient(gradient.clone()); let effects = [ Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false }, - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, position: StrokePosition::Inside, paint: paint.clone() }), + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 1.0, + position: StrokePosition::Inside, + paint: paint.clone(), + overprint: false, + }), Effect::InnerGlow(glow(paint.clone(), GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)), Effect::OuterGlow(glow(paint, GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)), ]; @@ -1220,21 +1327,103 @@ fn small_layers_composite_over_their_bounds() { fn many_layers_submit_inside_one_chunk() { // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is - // invalid"). 700 layers need well over 1000 passes in the document's only chunk. + // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is + // masked, so it is drawn in a pass of its own rather than batched with its neighbours. let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let modes: Vec = BlendMode::layer_modes().collect(); - for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() { + for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() { let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); l.blend = mode; l.opacity = 0.9; + l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); d.layers.push(l); } - let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}")); + let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.layers_batched, 0, "{stats:?}"); assert_eq!(stats.chunks, 1); assert!(stats.flushes > 0, "{stats:?}"); } +#[test] +fn runs_of_plain_layers_blend_in_one_pass() { + // Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at + // varied opacity and fill (the fill-special modes included), solid fills, layers that end + // inside the canvas, and the layers that end a run: masked, Dissolve, small (clipped to their + // bounds) and clipping groups. Then an edit in the middle, so the prefix cache resumes + // part-way up the stack and runs start at its checkpoint. + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(96, 64); + let modes: Vec = BlendMode::layer_modes().collect(); + for (i, mode) in modes.iter().copied().cycle().take(90).enumerate() { + let i32_ = i as i32; + let rect = if i % 5 == 2 { Rect::from_xywh(-8, 10, 70, 40) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("l", PixelFormat::RGBA8, rect, 900 + i as u32, 0.0); + l.blend = mode; + l.opacity = 0.35 + 0.05 * (i % 13) as f32; + l.fill_opacity = if i % 4 == 1 { 0.6 } else { 1.0 }; + if i % 11 == 7 { + l.mask = Some(mask(Rect::from_xywh(i32_ % 30, 0, 50, 64), 70 + i as u32, 0.0)); + } + if i % 23 == 9 { + l.clipped = true; + } + d.layers.push(l); + if i % 9 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.3, 0.6, 0.2, 0.4)))); + f.blend = modes[i % modes.len()]; + d.layers.push(f); + } + if i % 17 == 8 { + d.layers.push(noise_layer("small", PixelFormat::RGBA8, Rect::from_xywh(20, 20, 6, 5), 40 + i as u32, 0.5)); + } + } + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "no layers were batched: {stats:?}"); + let mid = d.layers.len() / 2; + d.layers[mid].opacity = 0.5; + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs, edited in the middle").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "{stats:?}"); +} + +#[test] +fn runs_of_normal_layers_blend_with_normal_code_alone() { + // Runs whose layers all blend Normal draw with `fs_layerbatch_normal`: varied opacity and fill + // over a partly transparent backdrop, masked layers leading runs, solid fills and layers that + // end inside the canvas, at every depth. Lab documents mix Normal in CIELAB, so their runs + // keep the full shader. + let Some(mut g) = gpu() else { return }; + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Lab, SampleType::U8)] + { + let mut d = Document::new("normal", Size::new(96, 64), mode, depth); + let fmt = d.pixel_format(); + d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 96, 64), 5, 0.0)); + for i in 0..40u32 { + let rect = if i % 5 == 3 { Rect::from_xywh(-6, 9, 70, 44) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("n", fmt, rect, 600 + i, if i % 3 == 0 { 0.0 } else { 0.4 }); + l.opacity = 0.2 + 0.07 * (i % 11) as f32; + l.fill_opacity = [1.0, 0.55, 0.0, 0.8][i as usize % 4]; + if i % 7 == 2 { + l.mask = Some(mask(Rect::from_xywh(i as i32 % 25, 0, 60, 64), 50 + i, 0.0)); + } + d.layers.push(l); + if i % 6 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.7, 0.2, 0.5, 0.6)))); + f.opacity = 0.45; + d.layers.push(f); + } + } + let what = format!("normal runs {mode:?} {depth:?}"); + let stats = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}")); + if mode == ColorMode::Lab { + assert!(stats.layers_batched > 0 && stats.normal_batches == 0, "{what}: {stats:?}"); + } else { + assert!(stats.normal_batches > 0, "{what}: {stats:?}"); + } + } +} + fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer { let mut l = Layer::raster(name, fmt); let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32); @@ -1330,6 +1519,7 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel { altitude: 35.0, use_global_light: true, gloss_contour: Contour::Linear, + gloss_anti_alias: false, highlight: FxCommon::new(BlendMode::Screen, 0.8), highlight_color: Color::rgb(1.0, 1.0, 0.9), shadow: FxCommon::new(BlendMode::Multiply, 0.7), @@ -1340,7 +1530,7 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel { } fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> StrokeFx { - StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint } + StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint, overprint: false } } fn checker_pattern() -> Pattern { @@ -1386,6 +1576,14 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { let mut bevel_contour = bevel(BevelStyle::InnerBevel, true, 8.0, 3.0); bevel_contour.gloss_contour = contour(); bevel_contour.use_global_light = false; + // #7360: a gloss contour that starts at 100 % (Rolling Slope - Descending), as on the type in + // the report; its flat face and slopes take the contour-mapped shading on both renderers. + let mut bevel_gloss_high = bevel(BevelStyle::InnerBevel, true, 8.0, 2.0); + bevel_gloss_high.gloss_contour = Contour::Custom { + name: "descending".into(), + points: vec![Cp { input: 0.0, output: 1.0 }, Cp { input: 0.4, output: 0.4 }, Cp { input: 1.0, output: 0.0 }], + corners: vec![], + }; vec![ ("drop shadow", vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0))]), ("drop shadow spread contour", vec![Effect::DropShadow(ds_contour)]), @@ -1411,6 +1609,29 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))], ), ("inner glow precise center", vec![Effect::InnerGlow(glow(pat_paint.clone(), GlowTechnique::Precise, 5.0, 0.0, GlowSource::Center))]), + // Dissolve effects (#6995): the same speckle as the CPU, pixel for pixel. + ( + "outer glow dissolve", + vec![Effect::OuterGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 0.8), + ..glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge) + })], + ), + ( + "inner glow dissolve gradient", + vec![Effect::InnerGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 1.0), + ..glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 7.0, 0.1, GlowSource::Edge) + })], + ), + ("drop shadow dissolve", vec![Effect::DropShadow(shadow(BlendMode::Dissolve, 0.75, 120.0, 6.0, 8.0, 0.0))]), + ( + "stroke outside dissolve", + vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Dissolve, 0.6), + ..stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))) + })], + ), ("bevel inner", vec![Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 7.0, 2.0))]), ("bevel outer", vec![Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 0.0))]), ("bevel emboss down", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, false, 8.0, 1.0))]), @@ -1452,6 +1673,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { })], ), ("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]), + ("bevel gloss starting high", vec![Effect::BevelEmboss(bevel_gloss_high)]), ("satin", vec![Effect::Satin(satin)]), ("satin inverted contour", vec![Effect::Satin(satin_inv)]), ("stroke outside colour", vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.95, 0.85, 0.1))))]), @@ -1570,6 +1792,60 @@ fn layer_effects_every_kind() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +#[test] +fn exterior_effects_merge_from_the_shape_at_any_fill() { + // Drop shadows and outer glows alone: the merge builds the layer from its shape (fill inside + // it, times its alpha) instead of passes of its own. Fill from 0 to 1, opacity, modes + // (including one that blends with the knocked-out shadow), both depths, a layer covering the + // canvas (no copy back), and the layer clipped onto a base (merged atop it). + let Some(mut g) = gpu() else { return }; + let mut ds = shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0); + let mut ds_through = shadow(BlendMode::Normal, 0.9, 45.0, 4.0, 5.0, 0.2); + ds_through.knocks_out = false; + let og = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge); + ds.use_global_light = true; + let stacks = [ + ("drop shadow", vec![Effect::DropShadow(ds.clone())]), + ("drop shadow, not knocked out", vec![Effect::DropShadow(ds_through)]), + ("outer glow", vec![Effect::OuterGlow(og.clone())]), + ("shadow and glow", vec![Effect::OuterGlow(og), Effect::DropShadow(ds)]), + ]; + for depth in [SampleType::U8, SampleType::U16] { + for (name, fx) in &stacks { + for (opacity, fill, blend) in [ + (1.0, 1.0, BlendMode::Normal), + (0.8, 0.6, BlendMode::Normal), + (1.0, 0.0, BlendMode::Normal), + (0.7, 0.35, BlendMode::Multiply), + (1.0, 0.5, BlendMode::Screen), + ] { + let what = format!("{name} {depth:?} opacity {opacity} fill {fill} {blend:?}"); + let mut d = fx_doc(96, 80, depth); + let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); + l.effects.items = fx.clone(); + (l.opacity, l.fill_opacity, l.blend) = (opacity, fill, blend); + d.layers.push(l.clone()); + fx_check(&mut g, &d, &what); + // The same layer clipped onto a base. + let mut base = blob("base", d.pixel_format(), 40.0, 44.0, 30.0, [0.9, 0.8, 0.3]); + base.effects.items.clear(); + d.layers.pop(); + d.layers.push(base); + l.clipped = true; + d.layers.push(l.clone()); + fx_check(&mut g, &d, &format!("{what}, clipped")); + // A partly transparent layer over the whole canvas. + let mut d = fx_doc(64, 48, depth); + let mut full = noise_layer("full", d.pixel_format(), Rect::from_xywh(0, 0, 64, 48), 7, 0.2); + full.effects.items = fx.clone(); + (full.opacity, full.fill_opacity, full.blend) = (opacity, fill, blend); + d.layers.push(full); + fx_check(&mut g, &d, &format!("{what}, full canvas")); + } + } + } +} + #[test] fn layer_effects_opacity_fill_blend_and_off_canvas() { let Some(mut g) = gpu() else { return }; @@ -1758,7 +2034,7 @@ fn layer_effects_on_shape_layers() { let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); let mut fill = Color::rgb(0.3, 0.6, 0.9); fill.alpha = alpha; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.effects.items = vec![ @@ -1796,7 +2072,7 @@ fn stroke_effects_on_filled_and_stroked_shapes() { paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); if masked { @@ -2151,7 +2427,15 @@ fn stroked_shapes_with_clipped_layers() { d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3)); let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]); let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() }; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), + stroke: Some(stroke), + live: None, + cache: None, + psd_raw: None, + ..ShapeLayer::default() + }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.blend = blend; @@ -2571,6 +2855,38 @@ fn pages_are_evicted_under_the_budget() { check(&mut g, &d, "after eviction"); } +/// #6563: a page texture that doesn't fit the budget is admitted by first dropping the least +/// recently used pages of other cells (never the current cell's). With a zero budget every page +/// created in a cell evicts the previous cell's pages, and the refresh still stays on the GPU +/// (budget pressure alone never ends it on the CPU) and matches the CPU compositor. +#[test] +fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(600, 500); + d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 61, 0.0)); + let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 62, 0.2); + top.blend = BlendMode::Multiply; + d.layers.push(top); + // Exercise page residency itself: lower-stack checkpoints could skip binding the pages. + g.paged.set_composite_cache_budget(0); + check(&mut g, &d, "warm"); + let all = g.paged.resident_bytes(); + g.paged.set_resident_budget(0); + for round in 0..2 { + let cpu = photocraft_compose::flatten(&d); + let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()) + .unwrap_or_else(|e| panic!("round {round}: a zero budget must not end the refresh on the CPU: {e}")); + let worst = worst_diff(&cpu.px, &out); + assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1); + assert!(s.cells > 1 && s.evicted > 0, "round {round}: {s:?}"); + // Evicted pages were created again (uploaded), and only the last cell's are left. + assert!(s.tiles_uploaded > 0, "round {round}: {s:?}"); + assert!(g.paged.resident_bytes() < all, "round {round}: {} of {all}", g.paged.resident_bytes()); + d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(40 + round * 250, 60, 90 + round * 250, 120), &[0.2, 0.8, 0.3, 1.0]); + } + check(&mut g, &d, "after zero-budget renders"); +} + #[test] fn the_focused_view_stays_resident_after_a_full_refresh() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); @@ -2618,3 +2934,93 @@ fn pass_through_clipping_base_with_an_adjustment() { d.layers.push(clip); check(&mut g, &d, "pass-through clipping base with an adjustment"); } + +/// #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] +fn soft_shapes_match_the_cpu() { + let Some(mut g) = gpu() else { return }; + use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (density, feather, stroked, fx) in [(1.0, 6.0, false, false), (0.4, 0.0, true, false), (0.6, 4.0, true, true), (1.0, 3.0, false, true)] { + let mut d = fx_doc(90, 70, depth); + let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); + let stroke = stroked.then(|| ShapeStroke { width: 3.0, paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + let mut l = Layer::new("shape", LayerContent::Shape(sh)); + if fx { + l.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0))]; + } + d.layers.push(l); + if stroked { + let mut c = noise_layer("clipped", d.pixel_format(), Rect::from_xywh(0, 0, 90, 70), 11, 0.9); + c.clipped = true; + d.layers.push(c); + } + let what = format!("{depth:?} density {density} feather {feather} stroked {stroked} fx {fx}"); + if fx { + fx_check(&mut g, &d, &what); + } else { + check(&mut g, &d, &what); + } + } + } +} + +/// An adjustment has no standalone source alpha: its opacity and mask gate binary coverage. +#[test] +fn dissolve_adjustment_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 32, 48), 31, 0.0)); + d.layers.push(adjustment); + check(&mut g, &d, "dissolve adjustment + mask"); +} + +/// Clipped pixels still use document-positioned noise, without changing the base alpha. +#[test] +fn dissolve_clipped_layer_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut clipped = Layer::new("clipped", LayerContent::Fill(Fill::Solid(Color::BLACK))); + clipped.clipped = true; + clipped.blend = BlendMode::Dissolve; + clipped.opacity = 0.5; + d.layers.push(clipped); + check(&mut g, &d, "dissolve clipped"); +} + +/// Layer opacity gates the completed effects stack, instead of fading its pixels. +#[test] +fn dissolve_effects_stack_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK))); + styled.blend = BlendMode::Dissolve; + styled.opacity = 0.5; + styled.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); + d.layers.push(styled); + check(&mut g, &d, "dissolve effects"); +} + +#[test] +fn dissolve_clipped_adjustment_matches_cpu() { + let Some(mut g) = gpu() else { return }; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("clipped adjustment", Size::new(272, 24), ColorMode::Rgb, fmt.sample); + let base = noise_layer("base", fmt, Rect::new(-3, -2, 268, 22), 47, 0.0); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.clipped = true; + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.6; + adjustment.fill_opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 260, 24), 48, 0.0)); + d.layers.extend([base, adjustment]); + check(&mut g, &d, &format!("clipped Dissolve adjustment {fmt:?}")); + diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}")); + } +} diff --git a/crates/gpu/tests/it/timing.rs b/crates/gpu/tests/it/timing.rs new file mode 100644 index 0000000000..9761a1d90d --- /dev/null +++ b/crates/gpu/tests/it/timing.rs @@ -0,0 +1,287 @@ +//! The canvas timing must measure GPU *execution*, not encode-and-submit (#7186). +//! +//! `composite_ms` reports the CPU cost of handing the composite to the driver, which on a +//! GPU-bound refresh is a small fraction of what the refresh costs — at 24 MP it read 7.7 ms for +//! work that took ~171 ms. `Compositor::measure_gpu_time` adds timestamp queries whose spans are +//! summed into a [`GpuTiming`]. This test pins that down from both sides: the measurement must +//! track the waited-for wall clock and must be several times the submit time, so an accidental +//! return to timing the submit fails it. +//! +//! **Hardware only.** A software rasteriser composites on the CPU, where submit and execution +//! are the same work, so the whole premise disappears and the test would pass on a bug. It +//! therefore refuses to run on a CPU adapter and says so, as every GPU timing measurement in +//! this project must (egui_kittest's `default_wgpu_setup` prefers CPU adapters on purpose). + +use std::time::Instant; + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_geom::Rect; +use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing}; + +/// Pixels in `r`, as the compositor counts them. +fn area(r: Rect) -> u64 { + r.width() as u64 * r.height() as u64 +} + +fn block_on(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::yield_now(); + } +} + +/// A real GPU with timestamp queries, or `None` with the reason printed. +fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&options)) else { + eprintln!("SKIP gpu timing: no adapter"); + return None; + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu || info.backend == wgpu::Backend::Noop { + // Not a skip we can be quiet about: a GPU timing test on llvmpipe measures nothing. + eprintln!("SKIP gpu timing: {} is a software adapter, which cannot show a GPU/CPU split", info.name); + return None; + } + if !adapter.features().contains(timing::FEATURES) { + eprintln!("SKIP gpu timing: {} has no timestamp queries", info.name); + return None; + } + let descriptor = wgpu::DeviceDescriptor { required_features: timing::FEATURES, ..Default::default() }; + let Ok((device, queue)) = block_on(adapter.request_device(&descriptor)) else { + eprintln!("SKIP gpu timing: no device with timestamp queries"); + return None; + }; + let mut comp = Compositor::new(&device); + assert!(comp.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build"); + eprintln!("gpu timing on {} ({:?}, {:?})", info.name, info.device_type, info.backend); + Some((device, queue, comp)) +} + +fn rnd(seed: u32, i: u32) -> f32 { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^= h >> 16; + (h & 0xffff) as f32 / 65535.0 +} + +/// A document whose composite is clearly GPU-bound: several full-canvas blended photo layers. +fn document(size: Size) -> Document { + let mut doc = Document::with_background("timing", size, ColorMode::Rgb, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); + let fmt = doc.pixel_format(); + let rect = Rect::from_xywh(0, 0, size.width, size.height); + for (i, blend) in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Overlay, BlendMode::SoftLight].into_iter().enumerate() { + let mut layer = Layer::raster(format!("l{i}"), fmt); + let channels = fmt.channels(); + let px: Vec = (0..rect.width() * rect.height()).flat_map(|p| (0..channels).map(move |c| rnd(7 + i as u32 * 131 + c as u32 * 7919, p))).collect(); + if let Some(s) = layer.surface_mut() { + s.write_region(rect, &px); + } + layer.blend = blend; + layer.opacity = 0.8; + doc.layers.push(layer); + } + doc +} + +/// Render `region` and return (submit ms, waited-for wall ms, the measurement). +fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option) { + let t0 = Instant::now(); + comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU"); + let submit_ms = t0.elapsed().as_secs_f64() * 1000.0; + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + // The readback map completes on a poll, and its callback runs on the next one. + let mut timing = None; + for _ in 0..4 { + timing = comp.take_gpu_timing(); + if timing.is_some() { + break; + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + } + (submit_ms, wall_ms, timing) +} + +#[test] +fn gpu_time_tracks_execution_not_submission() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, mut comp)) = gpu() else { return }; + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + // Warm the residency and pipelines: the first refresh uploads every layer. + let _ = measure(&device, &queue, &mut comp, &doc, full); + + // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`, + // and CPU contention on a shared machine only ever inflates it — one run that happened to + // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison + // while the measurement itself was right. The GPU side is unaffected by that contention, + // so taking the least-disturbed encode tests the same property without the false failure. + let mut best: Option<(f64, f64, Option)> = None; + for _ in 0..3 { + let run = measure(&device, &queue, &mut comp, &doc, full); + if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) { + best = Some(run); + } + } + let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time"); + let t = timing.expect("a hardware device with timestamp queries must report a GPU time"); + eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); + + assert_eq!(t.px, area(full), "the measurement carries the pixels of the refresh it measured"); + assert!(t.spans >= 1, "at least one command buffer was timed"); + + // The premise: on this adapter the refresh really is dominated by GPU execution. A machine + // fast enough that it isn't cannot tell the two timings apart, so say so instead of failing. + if wall_ms < 3.0 * submit_ms { + eprintln!("SKIP the comparison: this GPU finishes a 6 MP composite in {wall_ms:.1} ms against {submit_ms:.1} ms of encoding"); + return; + } + + // Timing the submit again (the bug) would report roughly `submit_ms` here. + assert!(t.ms > 3.0 * submit_ms, "gpu time {:.1} ms looks like the submit time {submit_ms:.1} ms, not the execution", t.ms); + // …and it must be the real execution, not merely a bigger number: the waited-for wall clock + // is encode + execute, so the GPU span sits below it and accounts for most of it. + assert!(t.ms <= wall_ms * 1.25, "gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + assert!(t.ms >= (wall_ms - submit_ms) * 0.5, "gpu time {:.1} ms is far below the {:.1} ms the refresh waited", t.ms, wall_ms - submit_ms); + + // A quarter of the canvas is a quarter of the per-pixel work: a constant or a stale value + // would not follow it. + let quarter = Rect::from_xywh(0, 0, size.width / 2, size.height / 2); + let (_, _, small) = measure(&device, &queue, &mut comp, &doc, quarter); + let s = small.expect("the smaller refresh is measured too"); + eprintln!("quarter: gpu {:.1} ms for {} px against {:.1} ms for {} px", s.ms, s.px, t.ms, t.px); + assert_eq!(s.px, area(quarter)); + assert!(s.ms < t.ms * 0.6, "a quarter-canvas refresh took {:.1} ms against the full canvas's {:.1} ms", s.ms, t.ms); +} + +#[test] +fn an_unmeasured_compositor_reports_nothing() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, _)) = gpu() else { return }; + let mut comp = Compositor::new(&device); + assert!(!comp.measures_gpu_time(), "timing is off until it is asked for"); + let doc = document(Size::new(256, 256)); + comp.render(&device, &queue, &doc, Rect::from_xywh(0, 0, 256, 256), |_, _| {}).expect("composites"); + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); +} + +#[test] +fn a_refresh_that_submits_several_times_is_one_measurement() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, mut comp)) = gpu() else { return }; + // A budget far below the document's layer pages makes the refresh evict as it goes, which is + // what makes it submit in batches: the measurement must span all of them, not just the last. + comp.set_memory_budget(24 << 20); + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + let _ = measure(&device, &queue, &mut comp, &doc, full); + let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full); + let t = timing.expect("a batched refresh is measured like any other"); + eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms); + assert_eq!(t.px, area(full)); + if t.spans < 2 { + eprintln!("SKIP the sum: this refresh still fit one command buffer"); + return; + } + assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + // What this bound excludes is summing only the last command buffer. A correct sum measures + // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so + // no single buffer of the several carries much over half of it, and timing only the last + // would land near 40 %. 0.6 sits between the two with room on both sides for a machine + // whose split is less even than this one's. + assert!( + t.ms >= (wall_ms - submit_ms) * 0.6, + "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?", + t.ms, + wall_ms - submit_ms + ); +} + +#[test] +fn refreshes_faster_than_the_readback_stay_valid() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, mut comp)) = gpu() else { return }; + // Refreshes arriving before the last measurement has been read back must simply go + // unmeasured. Mapping the readback buffer again while its first map is still pending is a + // validation error, and an unusable device drops the whole canvas to the CPU compositor. + let health = DeviceHealth::watch(&device); + comp.set_health(health.clone()); + let size = Size::new(600, 400); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + for i in 0..16 { + comp.render(&device, &queue, &doc, full, |_, _| {}).unwrap_or_else(|e| panic!("refresh {i} failed: {e}")); + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.fault(), None, "hurried refreshes must not fault the device"); + assert_eq!(health.errors(), 0, "no uncaptured wgpu errors"); +} + +/// A refresh that recorded nothing must not stop the timer for good. `finish` leaves the +/// measurement's slots protected by the pending readback, not by `flushed`; if `flushed` +/// survived a successful finish, the next refresh to fail while that readback was still in +/// flight would set `stopped` on the strength of the *earlier* refresh's flush — even though it +/// wrote no timestamps itself — and the compositor would silently never measure again. +#[test] +fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, _)) = gpu() else { return }; + let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries"); + + // A refresh that flushes once, then succeeds. + let mut first = device.create_command_encoder(&Default::default()); + let mut second = device.create_command_encoder(&Default::default()); + timer.start(&mut first, 1024); + timer.split(&mut first, &mut second); + queue.submit([first.finish()]); + timer.finish(&mut second); + queue.submit([second.finish()]); + timer.submitted(); + + // A later refresh fails before recording anything, because the readback is still in flight. + let mut third = device.create_command_encoder(&Default::default()); + timer.start(&mut third, 2048); + timer.cancel(); + drop(third); + + // The first measurement still lands... + let mut first_timing = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + first_timing = timer.take(); + if first_timing.is_some() { + break; + } + } + let landed = first_timing.expect("the flushed measurement completes"); + assert_eq!(landed.spans, 2, "both command buffers are one measurement"); + + // ...and the timer still measures afterwards, which is what the bug destroyed. + let mut next = device.create_command_encoder(&Default::default()); + timer.start(&mut next, 4096); + timer.finish(&mut next); + queue.submit([next.finish()]); + timer.submitted(); + let mut after = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + after = timer.take(); + if after.is_some() { + break; + } + } + let after = after.expect("the timer still measures after a refresh that recorded nothing"); + assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it"); +} diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/it/uniform_limits.rs similarity index 96% rename from crates/gpu/tests/uniform_limits.rs rename to crates/gpu/tests/it/uniform_limits.rs index aea8fb6b12..90dfa57aed 100644 --- a/crates/gpu/tests/uniform_limits.rs +++ b/crates/gpu/tests/it/uniform_limits.rs @@ -5,7 +5,7 @@ use photocraft_doc::{Document, Fill, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_gpu::{Compositor, DeviceHealth, plan, render_to_vec}; -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; fn fills(count: usize) -> Document { fills_size(count, Size::new(1, 1)) @@ -59,7 +59,7 @@ fn a_deep_stack_above_the_device_buffer_limit_returns_an_error_without_panicking let Some((adapter, device, queue)) = constrained_device() else { return }; let mut compositor = compositor(&adapter, &device); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); eprintln!( "uniform-limit reproduction: backend={:?}, buffer limit={}, page={}, passes={}", adapter.get_info().backend, @@ -80,7 +80,7 @@ fn an_oversized_stack_falls_back_without_faulting_the_watched_device() { let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); let fallback = match render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()) { Ok(_) => panic!("the oversized stack must return Unsupported for CPU fallback"), Err(_) => photocraft_compose::flatten(&deep), @@ -104,7 +104,8 @@ fn the_exact_buffer_limit_still_renders_with_cpu_parity() { let health = DeviceHealth::watch(&device); let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); - let boundary = fills(127); + // Every fill layer is one pass (`Kernel::LayerBlend`) after the clear. + let boundary = fills(254); assert_eq!(plan(&boundary).unwrap().passes.len(), 255, "one chunk plus255passes uses exactly65536bytes"); let expected = photocraft_compose::flatten(&boundary); let actual = render_to_vec(&mut compositor, &device, &queue, &boundary, boundary.bounds()).expect("the exact supported buffer boundary is accepted"); @@ -121,7 +122,7 @@ fn later_oversized_page_cells_are_rejected_before_render_or_encode_calls_the_sin let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = constrained_device() else { return }; let health = DeviceHealth::watch(&device); - let doc = fills_size(126, Size::new(4096, 2048)); + let doc = fills_size(252, Size::new(4096, 2048)); let region = photocraft_geom::Rect::new(2047, 0, 4096, 2048); assert_eq!(plan(&doc).unwrap().passes.len(), 253); // Default pages are2048, chunks1024: the narrow first page has2chunks and fits; diff --git a/crates/gpu/tests/vector_mask.rs b/crates/gpu/tests/it/vector_mask.rs similarity index 98% rename from crates/gpu/tests/vector_mask.rs rename to crates/gpu/tests/it/vector_mask.rs index 43d3d081e3..d0d7e7a012 100644 --- a/crates/gpu/tests/vector_mask.rs +++ b/crates/gpu/tests/it/vector_mask.rs @@ -47,6 +47,7 @@ fn painted(name: &str, fmt: PixelFormat, rgba: [f32; 4]) -> Layer { #[test] fn vector_masks_match_the_cpu() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some(mut g) = gpu() else { return }; for depth in [SampleType::U8, SampleType::U16] { for (inverted, density) in [(false, 1.0), (true, 1.0), (false, 0.6)] { diff --git a/crates/heif/README.md b/crates/heif/README.md index 8915129443..9d859e8401 100644 --- a/crates/heif/README.md +++ b/crates/heif/README.md @@ -30,5 +30,5 @@ inside it (fuzzing found two in 0.1.1) becomes `Error::Malformed`. The crate depends on no other PhotoCraft crate and knows nothing of `photocraft-codecs`' types; `codecs` → `heif` is the one allowed dependency between standalone crates (`cargo xtask layers`). -Tests on real files live in `photocraft-codecs` (`tests/heif.rs`, features `corpus,heif`, files +Tests on real files live in `photocraft-codecs` (`tests/it/heif.rs`, features `corpus,heif`, files fetched by `cargo xtask corpus --heif`). diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 7c88fc1b9c..218ccde5eb 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -64,6 +64,10 @@ pub struct Decoded { pub data: Vec, /// The primary image's ICC profile. pub icc: Option>, + /// The primary image's `nclx` colour description (inherited from its tiles like [`Self::icc`]). + /// The decoder has already applied its matrix and range; its primaries and transfer + /// characteristics describe the RGB samples returned. + pub nclx: Option, /// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors /// what the container says; it is returned verbatim. pub exif: Option>, @@ -71,6 +75,19 @@ pub struct Decoded { pub xmp: Option, } +/// A `colr` box of type `nclx`: ITU-T H.273 code points (CICP), as written. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Nclx { + /// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65… + pub primaries: u16, + /// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 16 PQ, 18 HLG… + pub transfer: u16, + /// Matrix coefficients (already applied by the decoder). + pub matrix: u16, + /// Full-range samples (already applied by the decoder). + pub full_range: bool, +} + /// Why a file could not be decoded. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { @@ -155,24 +172,37 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { .with_transforms(options.apply_transforms) .with_alpha(info.has_alpha); let decoded = heic_rs::decode(bytes, &raw).map_err(err)?; - let (icc, exif, xmp) = metadata(bytes); - Ok(Decoded { width: decoded.width, height: decoded.height, has_alpha: info.has_alpha, sixteen_bit: info.sixteen_bit, data: decoded.data, icc, exif, xmp }) + let (icc, nclx, exif, xmp) = metadata(bytes); + Ok(Decoded { + width: decoded.width, + height: decoded.height, + has_alpha: info.has_alpha, + sixteen_bit: info.sixteen_bit, + data: decoded.data, + icc, + nclx, + exif, + xmp, + }) } -/// The primary image's ICC profile, EXIF (TIFF structure) and XMP packet. Metadata is a +type Metadata = (Option>, Option, Option>, Option); + +/// The primary image's ICC profile, `nclx`, EXIF (TIFF structure) and XMP packet. Metadata is a /// courtesy: a broken metadata item never fails a decode whose pixels came out fine. -fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) { +fn metadata(bytes: &[u8]) -> Metadata { let Ok(ctx) = heic_rs::context::Context::open(bytes) else { - return (None, None, None); + return (None, None, None, None); }; let id = ctx.meta.primary; let icc = icc_for_item(&ctx, id); + let nclx = nclx_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), Cow::Owned(b) => String::from_utf8(b).ok(), }); - (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) + (icc, nclx, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } /// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries @@ -188,13 +218,26 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) /// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no /// override for it, so that half has to be fixed upstream. fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { - let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + inherited(ctx, item, |p| ctx.icc(p).map(<[u8]>::to_vec)) +} + +/// The `nclx` colour description of `item`, inherited from its tiles as [`icc_for_item`] does. +/// A 10-bit HDR HEIC declares its BT.2020 primaries and HLG or PQ transfer only here (#6752). +fn nclx_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option { + inherited(ctx, item, |p| { + p.nclx.map(|n| Nclx { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix_code, full_range: n.range == heic_rs::Range::Full }) + }) +} + +/// `get` of `item`'s properties, or of the first of its grid tiles that has it. +fn inherited(ctx: &heic_rs::context::Context<'_>, item: u32, get: impl Fn(&heic_rs::props::ItemProps<'_>) -> Option) -> Option { + let own = ctx.props(item).ok().and_then(|p| get(&p)); if own.is_some() { return own; } // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. let (_, tiles) = ctx.grid(item).ok()??; - tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| get(&p))) } #[cfg(test)] @@ -297,6 +340,17 @@ mod tests { bx(b"colr", &p) } + /// A `colr` box of type `nclx`, laid out as ISO/IEC 23008-12 says: three big-endian `u16` + /// code points, then the full-range flag in the top bit of a byte. + fn colr_nclx(primaries: u16, transfer: u16, matrix: u16, full_range: bool) -> Vec { + let mut p = b"nclx".to_vec(); + for v in [primaries, transfer, matrix] { + p.extend_from_slice(&v.to_be_bytes()); + } + p.push(if full_range { 0x80 } else { 0 }); + bx(b"colr", &p) + } + fn ispe(w: u32, h: u32) -> Vec { let mut p = w.to_be_bytes().to_vec(); p.extend_from_slice(&h.to_be_bytes()); @@ -340,16 +394,21 @@ mod tests { /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the /// property essential, as macOS writes it. fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + grid_file_colr(grid_icc.map(colr_prof), tile_icc.map(colr_prof)) + } + + /// [`grid_file`] with whole `colr` boxes. + fn grid_file_colr(grid_colr: Option>, tile_colr: Option>) -> Vec { let mut ipco = ispe(64, 64); let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); - if let Some(icc) = grid_icc { + if let Some(colr) = grid_colr { index += 1; - ipco.extend_from_slice(&colr_prof(icc)); + ipco.extend_from_slice(&colr); grid_assoc.push(0x80 | index); } - if let Some(icc) = tile_icc { + if let Some(colr) = tile_colr { index += 1; - ipco.extend_from_slice(&colr_prof(icc)); + ipco.extend_from_slice(&colr); tile_assoc.push(0x80 | index); } let mut iprp = bx(b"ipco", &ipco); @@ -374,10 +433,15 @@ mod tests { /// One coded picture rather than a derivation, with `icc` on the item itself. fn picture_file(icc: Option<&[u8]>) -> Vec { + picture_file_colr(icc.map(colr_prof)) + } + + /// [`picture_file`] with a whole `colr` box. + fn picture_file_colr(colr: Option>) -> Vec { let mut ipco = ispe(64, 64); let mut assoc = vec![0x81]; - if let Some(icc) = icc { - ipco.extend_from_slice(&colr_prof(icc)); + if let Some(colr) = colr { + ipco.extend_from_slice(&colr); assoc.push(0x82); } let mut iprp = bx(b"ipco", &ipco); @@ -436,4 +500,30 @@ mod tests { let ctx = ctx_of(&bare); assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); } + /// An iPhone 10-bit HDR HEIC's description (#6752): BT.2020 primaries, ARIB STD-B67 (HLG) + /// transfer, BT.2020 non-constant-luminance matrix, full range. + const HLG: Nclx = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: true }; + + #[test] + fn a_coded_pictures_nclx_is_read() { + let bytes = picture_file_colr(Some(colr_nclx(9, 18, 9, true))); + let ctx = ctx_of(&bytes); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); + + let sdr = picture_file_colr(Some(colr_nclx(1, 13, 6, false))); + let ctx = ctx_of(&sdr); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(Nclx { primaries: 1, transfer: 13, matrix: 6, full_range: false })); + + let bare = picture_file(Some(TILE_ICC)); + let ctx = ctx_of(&bare); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), None, "an ICC profile is not an nclx"); + } + + #[test] + fn a_grid_inherits_its_tiles_nclx() { + let bytes = grid_file_colr(None, Some(colr_nclx(9, 18, 9, true))); + let ctx = ctx_of(&bytes); + assert!(ctx.props(ctx.meta.primary).unwrap().nclx.is_none(), "the grid item carries no colr of its own"); + assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); + } } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 9bd4c559c5..eb8c0b00d7 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -31,6 +31,7 @@ photocraft-text = { workspace = true } photocraft-paint = { workspace = true } photocraft-vector = { workspace = true } photocraft-format = { path = "../format" } +craft-docpath = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } diff --git a/crates/io/examples/big_psb_check.rs b/crates/io/examples/big_psb_check.rs index 9b1eb80d47..3b1c21034b 100644 --- a/crates/io/examples/big_psb_check.rs +++ b/crates/io/examples/big_psb_check.rs @@ -1,14 +1,10 @@ //! Opens the files written by `photocraft-psd`'s `big_psb` example and checks their pixels. //! //! `cargo run --release -p photocraft-io --example big_psb_check -- ...` +//! +//! The opt-in tests in `tests/it/large_files.rs` generate and check the same files. -fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 { - if c == 3 { - return 255; - } - let x = if rle { 0 } else { x }; - (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8 -} +use photocraft_psd::fixtures::sample; fn main() { for path in std::env::args().skip(1) { diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index 2736e49d06..cbb2ad303c 100644 --- a/crates/io/examples/gen_abr_library.rs +++ b/crates/io/examples/gen_abr_library.rs @@ -10,7 +10,7 @@ //! //! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000], //! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100], -//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap. +//! `--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::*; diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index a7f295d463..fddef567a9 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -437,7 +437,7 @@ fn main() { } } -/// The corpus oracle table (as `tests/corpus.rs`), files in parallel, without a test build. +/// The corpus oracle table (as `tests/it/corpus.rs`), files in parallel, without a test build. fn corpus_summary(root: &std::path::Path) { fn collect(dir: &std::path::Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 549ac40970..0d3f497c75 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -473,7 +473,8 @@ impl Mapper<'_> { tilt_y: (num(d, "brushPoseTiltY").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32, rotation: num(d, "brushPoseAngle").unwrap_or(0.0).rem_euclid(360.0) as f32, pressure: pct(d, "brushPosePressure").unwrap_or(1.0).clamp(0.0, 1.0), - override_tilt: boolean(d, "overridePoseTiltX").unwrap_or(false) || boolean(d, "overridePoseTiltY").unwrap_or(false), + override_tilt_x: boolean(d, "overridePoseTiltX").unwrap_or(false), + override_tilt_y: boolean(d, "overridePoseTiltY").unwrap_or(false), override_rotation: boolean(d, "overridePoseAngle").unwrap_or(false), override_pressure: boolean(d, "overridePosePressure").unwrap_or(false), }; diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 501e331af9..26f68d7aa0 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -128,6 +128,43 @@ fn grit_pattern() -> PsdPattern { } } +#[test] +fn pose_axes_abr_import_preserves_independent_stylus_tilt() { + let input = photocraft_paint::StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, wheel: 0.24, time: 73.0, ..Default::default() }; + for subversion in [1, 2] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let preset = Descriptor::new("brushPreset") + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 47.0)))) + .with("useBrushPose", Value::Boolean(true)) + .with("brushPoseTiltX", prc(50.0)) + .with("brushPoseTiltY", prc(-20.0)) + .with("overridePoseTiltX", Value::Boolean(x)) + .with("overridePoseTiltY", Value::Boolean(y)); + let bytes = write_v6(subversion, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Pose axes").unwrap(); + assert_eq!(imported.presets.len(), 1); + let b = &imported.presets[0].brush; + assert!(b.pose.enabled); + assert_eq!((b.pose.tilt_x, b.pose.tilt_y), (45.0, -18.0), "ABR percentages retain the W3C degree conversion"); + let mut expected = input; + if x { + expected.tilt_x = 45.0; + } + if y { + expected.tilt_y = -18.0; + } + assert_eq!( + photocraft_paint::dynamics::apply_pose(b, input), + expected, + "v6.{subversion}, X={x}, Y={y}: one axis must not override the other's tablet data" + ); + let stored = serde_json::to_vec(&imported.presets[0]).unwrap(); + let restored: photocraft_paint::BrushPreset = serde_json::from_slice(&stored).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&restored.brush, input), expected, "preset persistence retains the independent flags"); + } + } +} + #[test] fn v6_settings_map_onto_brush_sections() { for sub in [1, 2] { @@ -166,7 +203,7 @@ fn v6_settings_map_onto_brush_sections() { assert!(b.transfer.enabled); assert_eq!((b.transfer.opacity.control, b.transfer.opacity.minimum), (Control::PenPressure, 0.1)); assert_eq!((b.transfer.flow.control, b.transfer.flow.jitter), (Control::Fade, 0.15)); - assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt); + assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt_x && !b.pose.override_tilt_y); assert_eq!((b.pose.tilt_x, b.pose.rotation), (45.0, 90.0)); assert!(b.wet_edges && b.noise && b.build_up); assert_eq!((b.opacity, b.flow, b.smoothing.amount, b.pressure_size), (0.8, 0.6, 0.4, true)); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index d4bc312690..9cd7479b81 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -228,8 +228,10 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { *r = HueRange { hue: h, saturation: s, lightness: l, bounds: HueRange::canonical_bounds([a, b, c, d]) }; } } + let hue = f32::from(bei16(data, base)?); Some(Adjustment::HueSaturation { - hue: f32::from(bei16(data, base)?), + // Photoshop stores the colorize hue signed (200 as -160); the model keeps 0..360. + hue: if colorize { hue.rem_euclid(360.0) } else { hue }, saturation: f32::from(bei16(data, base + 2)?), lightness: f32::from(bei16(data, base + 4)?), colorize, @@ -328,6 +330,22 @@ pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channe } } +/// `true` for a `grdm` block exactly as older PhotoCraft builds wrote it: [`write_gradient_map`]'s +/// output without the two padding bytes, which makes Photoshop refuse the file (#8070). Blocks +/// from other writers, whatever their length, are not matched. +pub(crate) fn is_unpadded_photocraft_gradient_map(data: &[u8]) -> bool { + if data.len().is_multiple_of(4) { + return false; + } + match parse_gradient_map(data) { + Some(Adjustment::GradientMap { stops, reverse, dither }) => { + let padded = write_gradient_map(&stops, reverse, dither); + padded.len() == data.len() + 2 && padded.starts_with(data) + } + _ => false, + } +} + /// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so /// the stops are reproduced exactly). fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec { @@ -370,6 +388,8 @@ fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> put16(&mut v, x); } put16(&mut v, 0); + // Photoshop includes the two-byte pad in the `grdm` length when the block is 2 mod 4 (#8070). + v.resize(v.len().next_multiple_of(4), 0); v } @@ -621,10 +641,16 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { put16(&mut v, 2); v.push(u8::from(*colorize)); v.push(0); - for _ in 0..2 { - for x in [hue, saturation, lightness] { - v.extend_from_slice(&clamp_i16(*x).to_be_bytes()); - } + // 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. @@ -827,7 +853,8 @@ mod tests { // Smoothness 100 % (4096) bends a three-stop ramp. let three = vec![(0.0, [0.0; 3]), (0.25, [1.0, 0.0, 0.0]), (0.75, [0.0, 0.0, 1.0]), (1.0, [1.0; 3])]; let mut smooth = write_gradient_map(&three, false, false); - let at = smooth.len() - 2 - 16 - 2 - 4 - 2 - 2 - 4 - 2 - 2 - 2; + // v1 header/name/count + colour stops + transparency stops + expansion count. + let at = 48 + three.len() * 20; smooth[at..at + 2].copy_from_slice(&4096u16.to_be_bytes()); let (a, b) = (at_t(&write_gradient_map(&three, false, false), 0.4), at_t(&smooth, 0.4)); assert!((0..3).any(|k| (a[k] - b[k]).abs() > 1e-3), "{a:?} {b:?}"); @@ -948,6 +975,21 @@ mod tests { assert_eq!(back, a); } + /// Photoshop keeps the colorize triplet at byte 4 and the master triplet at byte 10, and fills + /// the inactive one with its defaults: colorize (30, 25, 0), master (0, 0, 0). The colorize hue + /// is stored signed (200 -> -160). + #[test] + fn hue2_writes_each_triplet_in_its_own_slot() { + let triplets = |a: &Adjustment| -> Vec { + let blocks = write(a); + (0..6).map(|i| bei16(&blocks[0].1, 4 + 2 * i).unwrap_or(i16::MIN)).collect() + }; + let on = Adjustment::HueSaturation { hue: 200.0, saturation: 40.0, lightness: 10.0, colorize: true, ranges: HueRange::defaults() }; + assert_eq!(triplets(&on), [-160, 40, 10, 0, 0, 0]); + let off = Adjustment::HueSaturation { hue: -30.0, saturation: 20.0, lightness: 5.0, colorize: false, ranges: HueRange::defaults() }; + assert_eq!(triplets(&off), [30, 25, 0, -30, 20, 5]); + } + #[test] fn roundtrips() { rt(Adjustment::Invert); diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..e6125ef980 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -28,6 +28,10 @@ pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"]; const MAX_PIXELS: u64 = 64 << 20; /// Canvas side in pixels. const MAX_SIDE: f64 = 300_000.0; +/// Decoded pixel bytes allowed across the whole import: the caps above are per call, so a small +/// container of embedded images could still decode (and keep) a surface each until the process +/// dies. ORA and KRA budget their decoded pixels the same way. +const MAX_PIXEL_BYTES: u64 = 1 << 30; pub fn has_extension(name: &str) -> bool { name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) @@ -82,6 +86,20 @@ struct Builder { /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset. format: PixelFormat, canvas: Rect, + /// Decoded pixel bytes left for embedded images and their resampled surfaces. + pixel_budget: u64, +} + +/// Deduct `cost` bytes from a decoded-pixel budget; `false` (leaving the budget alone) when it +/// cannot cover them. +fn charge(budget: &mut u64, cost: u64) -> bool { + match budget.checked_sub(cost) { + Some(rest) => { + *budget = rest; + true + } + None => false, + } } fn affine(a: af::Affine) -> Affine { @@ -108,7 +126,7 @@ impl Builder { let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = d.dpi as f32; let canvas = Rect::new(0, 0, size.width as i32, size.height as i32); - Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas }) + Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas, pixel_budget: MAX_PIXEL_BYTES }) } fn warn(&mut self, w: &str) { @@ -424,7 +442,7 @@ impl Builder { dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32), }) }); - let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); sh } @@ -494,6 +512,10 @@ impl Builder { match photocraft_codecs::decode_with(bytes, &opts) { Ok(d) => { let (w, h) = d.dimensions(); + if !charge(&mut self.pixel_budget, u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4)) { + self.warn("the document's embedded images need more pixel memory than the import allows; the rest were left out"); + return None; + } Some((w, h, d.to_rgba8())) } Err(_) => { @@ -548,6 +570,10 @@ impl Builder { let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0; let inv = m.inverse()?; let (rw, rh) = (r.width() as usize, r.height() as usize); + if !charge(&mut self.pixel_budget, (rw as u64).saturating_mul(rh as u64).saturating_mul(channels as u64)) { + self.warn("the document's images need more pixel memory than the import allows; the rest were left out"); + return None; + } let mut buf = vec![0u8; rw * rh * channels]; let at = |x: i64, y: i64, ch: usize| -> f64 { if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) { @@ -619,3 +645,75 @@ fn color(c: ap::Color) -> Color { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A transparent 64×64 page holding one image node per entry; the identity transform maps + /// each image onto the page's top-left corner. + fn affinity_document(images: Vec<(u32, u32, Pixels)>) -> af::Document { + let nodes = images + .into_iter() + .enumerate() + .map(|(i, (width, height, pixels))| af::Node { + class: photocraft_affinity::synth::tag(b"ImgN"), + name: format!("Image {i}"), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Image(af::Image { width, height, pixels, transform: af::Affine::IDENTITY }), + mask: None, + pixel_mask: None, + children: Vec::new(), + }) + .collect(); + af::Document { + dpi: 72.0, + spreads: vec![af::Spread { bounds: af::Rect { x0: 0.0, y0: 0.0, x1: 64.0, y1: 64.0 }, pages: Vec::new(), transparent: true, nodes }], + saved_by: None, + warnings: Vec::new(), + } + } + + #[test] + fn the_pixel_budget_refuses_what_does_not_fit() { + let mut budget = 100u64; + assert!(charge(&mut budget, 60)); + assert!(!charge(&mut budget, 60), "a later image cannot overdraw the import's budget"); + assert_eq!(budget, 40, "a refused charge leaves the budget alone"); + } + + #[test] + fn images_past_the_pixel_budget_are_left_out() { + // Two 64×64 RGBA images cost 16 KiB each when resampled; a 16 KiB budget admits one and + // the second is dropped with a warning instead of being staged anyway. + let d = affinity_document(vec![(64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4])), (64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4]))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 64 * 64 * 4; + b.document(&d).unwrap(); + assert_eq!(b.doc.layers.len(), 1, "the image past the budget must not import"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + } + + #[test] + fn embedded_images_past_the_pixel_budget_are_not_decoded_into_it() { + // An 8×8 embedded PNG costs 256 bytes decoded and 256 more resampled. + let png = { + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + let d = affinity_document(vec![(8, 8, Pixels::Encoded(png))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 8 * 8 * 4 - 1; + b.document(&d).unwrap(); + assert!(b.doc.layers.is_empty(), "the embedded image must not import past the budget"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + // The same image imports when the budget covers the decode and the resample. + let mut ok = Builder::new("room", &d).unwrap(); + ok.pixel_budget = 8 * 8 * 4 * 2; + ok.document(&d).unwrap(); + assert_eq!(ok.doc.layers.len(), 1, "within the budget the image imports as a smart object"); + } +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..1ae5e1fffa 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -170,6 +170,11 @@ pub fn color_from_desc(d: &Descriptor) -> Option { b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0), // CIE L*a*b*: L 0..100, a / b −128..127 (stored like Lab pixels: offset by 128). b"LbCl" => Color { mode: ColorMode::Lab, c: [g("Lmnc")? / 100.0, (g("A ")? + 128.0) / 255.0, (g("B ")? + 128.0) / 255.0, 0.0], alpha: 1.0 }, + // HSB: hue an angle in degrees, saturation / brightness 0..100. + b"HSBC" => { + let [r, gr, b] = crate::channel_map::hsb_to_rgb(g("H ")?, g("Strt")? / 100.0, g("Brgh")? / 100.0); + Color::rgb(r, gr, b) + } // Newer Photoshop writes 0..1 floats. _ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?), _ => { @@ -252,7 +257,8 @@ pub(crate) fn gradient_style_value(s: GradientStyle) -> Value { pub(crate) type Stops = (Vec<(f32, Color)>, Vec<(f32, f32)>); /// Stops of a `Grdn` descriptor, baked to Photoshop's interpolation (smoothness `Intr`, stop -/// midpoints, and the parent's interpolation method when given, see [`gradient_method`]). +/// midpoints, and the parent's interpolation method when given, see [`gradient_method`]); the +/// opacity stops as [`baked_opacity_stops`]. pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> Stops { let raw = gradient_stops_raw(grad); let stops = if raw.stops.len() >= 2 { @@ -260,7 +266,7 @@ pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> S } else { raw.stops }; - (stops, raw.opacity) + (stops, baked_opacity_stops(grad, method)) } /// A `Grdn` descriptor's stops as stored: colour stops (sorted), the midpoint of each segment @@ -294,6 +300,29 @@ pub(crate) fn gradient_stops_editable(grad: &Descriptor, method: Option<&[u8]>) raw } +/// The exact, constant-RGB subset of Photoshop noise gradients. If every RGB component has the +/// same minimum and maximum, the generated field is constant regardless of seed or roughness. +/// Other models, transparency, and varying ranges are not approximated. +fn constant_rgb_noise_color(grad: &Descriptor) -> Option { + if enum_of(grad, "GrdF")? != b"ClNs" || enum_of(grad, "ClrS")? != b"RGBC" || bool_of(grad, "ShTr") { + return None; + } + let components = |key: &str| -> Option<[f32; 4]> { + let Some(Value::List(values)) = grad.get(key) else { return None }; + if values.len() != 4 { + return None; + } + let converted: Vec = values.iter().map(|value| num(Some(value)).map(|v| v as f32)).collect::>()?; + converted.try_into().ok() + }; + let min = components("Mnm ")?; + let max = components("Mxm ")?; + if min.iter().zip(max).take(3).any(|(a, b)| !a.is_finite() || *a < 0.0 || *a > 100.0 || *a != b) { + return None; + } + Some(Color::rgb(min[0] / 100.0, min[1] / 100.0, min[2] / 100.0)) +} + fn gradient_stops_raw(grad: &Descriptor) -> RawStops { let mut stops = Vec::new(); let mut mids = Vec::new(); @@ -376,26 +405,27 @@ pub(crate) fn gradient_desc_with(stops: &[(f32, Color)], opacity: &[(f32, f32)], .with("Trns", Value::List(trns)) } -/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the -/// same curve as the colour stops, Classic interpolation), densely sampled. -fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> { +/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness, opacity midpoints and +/// interpolation method applied (densely sampled; as stored when the interpolation is plain). +/// Gradient fill layers and the gradient layer effects share it: Photoshop renders their +/// transparency identically. +fn baked_opacity_stops(grad: &Descriptor, method: Option<&[u8]>) -> Vec<(f32, f32)> { let mut stops = Vec::new(); let mut mids = Vec::new(); if let Some(Value::List(items)) = grad.get("Trns") { for it in items { if let Value::Descriptor(s) = it { - let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0; - stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0))); + stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0)); mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0); } } } + // Midpoint k applies to the segment after stop k (stored on the stop that ends it). let mut order: Vec = (0..stops.len()).collect(); order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0)); let mids: Vec = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect(); let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32); - let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops }; - baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect() + crate::gradient_bake::bake_opacity(stops, &mids, smooth, crate::gradient_bake::Method::from_code(method)) } /// Parses a fill block (`SoCo`, `GdFl`, `PtFl`). @@ -413,16 +443,27 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { b"GdFl" => { let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); - let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d))); + let gradient = get_desc(d, "Grad"); + let raw = gradient.map(|g| gradient_stops_editable(g, gradient_method(d))); let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default(); - // Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when - // the interpolation is plain); none when fully opaque. - let mut opacity_stops = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default(); + // Opacity stops with Photoshop's smoothness, opacity midpoints and method applied + // (raw when the interpolation is plain); none when fully opaque. + let mut opacity_stops = get_desc(d, "Grad").map(|g| baked_opacity_stops(g, gradient_method(d))).unwrap_or_default(); if opacity_stops.iter().all(|o| o.1 >= 1.0 - 1e-4) { opacity_stops.clear(); } if stops.is_empty() { - stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + // A provably constant-RGB noise gradient renders one exact colour. Any other + // noise gradient (variable ranges, transparency, other models) has no stops to + // read: keep the placeholder ramp rather than failing the whole fill — the + // layer stays a fill layer, Photoshop's own pixels ride along as `fill_cache`, + // and the raw `GdFl` block survives the round trip byte for byte. Failing + // here would import the layer as pixels and drop `GdFl` on save (#6272). + stops = gradient + .filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) + .and_then(constant_rgb_noise_color) + .map(|color| vec![(0.0, color), (1.0, color)]) + .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); } let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0); let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc"))); @@ -525,22 +566,25 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, ) } -/// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, -/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend, +/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4 +/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the +/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` /// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - warp_of(get_desc(&d, "warp")?).flatten() + // A split (quilt) warp is stored under `quiltWarp`; `warp` then holds no usable mesh. + warp_of(get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp"))?).flatten() } -/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) -/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it -/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). -fn warp_of(w: &Descriptor) -> Option> { +/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` +/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves +/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is +/// neither one 4 × 4 patch nor a consistent quilt grid). +pub(crate) fn warp_of(w: &Descriptor) -> Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; if style == WarpStyle::None { @@ -561,16 +605,81 @@ fn warp_of(w: &Descriptor) -> Option> { _ => None, }; let (xs, ys) = (vals("Hrzn")?, vals("Vrtc")?); - if xs.len() != 16 || ys.len() != 16 { + if xs.len() != ys.len() { return None; } - let mesh = BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect() }; + let points: Vec<[f64; 2]> = xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect(); + let mesh = if points.len() == 16 && env.get("quiltSliceX").is_none() { + BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points } + } else { + quilt_mesh(w, env, points)? + }; warp.mesh = mesh.is_valid().then_some(mesh); warp.mesh.as_ref()?; } Some((!warp.is_identity()).then_some(warp)) } +/// The most patches a `quiltWarp` is read with along each side. +pub(crate) const QUILT_MAX_PATCHES: usize = 64; + +/// The control-point grid of a split ("quilt") custom warp. `deformNumCols` × `deformNumRows` +/// control points, three per patch side plus one, in row-major order; `quiltSliceX` and +/// `quiltSliceY` are the patch boundaries in the mesh's own units, first and last at its edges, +/// which become the knots on the unit square. `None` for anything inconsistent. +fn quilt_mesh(w: &Descriptor, env: &Descriptor, points: Vec<[f64; 2]>) -> Option { + // Photoshop's split grid tops out far below this; it only bounds what a file can claim. + const MAX_SIDE: usize = 3 * QUILT_MAX_PATCHES + 1; + let count = |k: &str| -> Option { + let n = num(w.get(k))?; + (n.is_finite() && n >= 4.0 && n <= MAX_SIDE as f64 && n.fract() == 0.0).then_some(n as usize) + }; + let (cols, rows) = (count("deformNumCols")?, count("deformNumRows")?); + if (cols - 1) % 3 != 0 || (rows - 1) % 3 != 0 || points.len() != cols.checked_mul(rows)? { + return None; + } + let us = quilt_knots(env, "quiltSliceX", (cols - 1) / 3)?; + let vs = quilt_knots(env, "quiltSliceY", (rows - 1) / 3)?; + Some(photocraft_geom::warp::BezierMesh { us, vs, points }) +} + +/// The knots of a quilt's patch boundaries `key` (`quiltSliceX` or `quiltSliceY` in its +/// `customEnvelopeWarp`): `patches + 1` strictly increasing slices, mapped onto `0..=1` with the +/// first and last at the ends. `None` for anything else. +pub(crate) fn quilt_knots(env: &Descriptor, key: &str, patches: usize) -> Option> { + // Stored as an object array holding one unit-float list under the same key; a plain list + // of numbers is taken too. + let s: Vec = match env.get(key)? { + Value::ObjectArray(arr) => match arr.body.get(key)? { + Value::UnitFloats { values, .. } => values.clone(), + Value::List(l) => l.iter().map(|v| num(Some(v))).collect::>()?, + _ => return None, + }, + Value::List(l) => l.iter().map(|v| num(Some(v))).collect::>()?, + _ => return None, + }; + let (first, last) = (*s.first()?, *s.last()?); + let span = last - first; + if s.len() != patches + 1 || !(span.is_finite() && span > 0.0) || !s.windows(2).all(|p| p[1] > p[0]) { + return None; + } + let n = s.len() - 1; + Some( + s.iter() + .enumerate() + .map(|(i, v)| { + if i == 0 { + 0.0 + } else if i == n { + 1.0 + } else { + (v - first) / span + } + }) + .collect(), + ) +} + /// Text content and transform from a `TySh` block (best effort). pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); @@ -593,13 +702,12 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// single-patch custom warp's 16 control points, which may spill past the source, else the source -/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the -/// placement. A degenerate or non-finite box falls back to the source rect. +/// custom warp's control points (one patch or a split grid), which may spill past the source, +/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice +/// and stretches the placement. A degenerate or non-finite box falls back to the source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) - else { + let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { return rect; }; let inf = f64::INFINITY; @@ -757,6 +865,43 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool { #[cfg(test)] mod tests { + use super::*; + + fn noise_gradient_fill(min: [i32; 4], max: [i32; 4], add_transparency: bool) -> Descriptor { + let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect()); + let gradient = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") }) + .with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") }) + .with("ShTr", Value::Boolean(add_transparency)) + .with("VctC", Value::Boolean(true)) + .with("RndS", Value::Integer(9052369)) + .with("Smth", Value::Integer(2048)) + .with("Mnm ", values(min)) + .with("Mxm ", values(max)); + Descriptor::new("null").with("Grad", Value::Descriptor(gradient)) + } + + #[test] + fn constant_rgb_noise_fill_uses_its_exact_colour() { + use photocraft_doc::Fill; + let fill = fill_from_desc(b"GdFl", &noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], false)); + let Some(Fill::Gradient { stops, .. }) = fill else { panic!("constant noise gradient") }; + assert_eq!(stops, vec![(0.0, Color::rgb(0.5, 0.25, 0.0)), (1.0, Color::rgb(0.5, 0.25, 0.0))]); + } + + #[test] + fn variable_or_transparent_noise_stays_a_fill_with_the_placeholder_ramp() { + use photocraft_doc::Fill; + // Photoshop's default Noise gradient (min 0, max 100) and a transparent variant: no + // exact colour is provable, but the fill must parse — the layer keeps its type, its + // stored pixels (`fill_cache`) and its raw `GdFl` block. Only the uncached preview + // shows the placeholder ramp. + for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] { + let Some(Fill::Gradient { stops, .. }) = fill_from_desc(b"GdFl", &descriptor) else { panic!("variable noise gradient must stay a fill layer") }; + assert_eq!(stops, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); + } + } + /// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a /// gradient fill's opacity stops reach the model (as its opacity stops; written from stop /// alpha) and survive a write; the ramp's alpha follows them. @@ -822,6 +967,30 @@ mod tests { assert!(super::color_from_desc(&Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0))).is_none()); } + /// Colours stored as HSB (`HSBC`: `H ` an angle in degrees, `Strt`/`Brgh` 0..100), as in + /// psd-tools `stroke-color-descriptors-hsb-with-rgb-mode.psd`, used to be dropped (#6774). + #[test] + fn hsb_descriptor_colours_parse() { + use photocraft_doc::Fill; + use photocraft_psd::descriptor::{Descriptor, Value}; + let hsb = |h: f64, s: f64, b: f64| { + Descriptor::new("HSBC").with("H ", Value::UnitFloat { unit: *b"#Ang", value: h }).with("Strt", Value::Double(s)).with("Brgh", Value::Double(b)) + }; + let close = |c: [f32; 3], e: [f32; 3]| c.iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-5); + let green = super::color_from_desc(&hsb(120.0, 100.0, 100.0)).unwrap().to_rgb(); + assert!(close(green, [0.0, 1.0, 0.0]), "{green:?}"); + let white = super::color_from_desc(&hsb(0.0, 0.0, 100.0)).unwrap().to_rgb(); + assert!(close(white, [1.0, 1.0, 1.0]), "{white:?}"); + let half = super::color_from_desc(&hsb(240.0, 50.0, 50.0)).unwrap().to_rgb(); + assert!(close(half, [0.25, 0.25, 0.5]), "{half:?}"); + // A solid fill (`SoCo`) with an HSB colour is read, not lost. + let soco = Descriptor::new("null").with("Clr ", Value::Descriptor(hsb(0.0, 100.0, 100.0))); + let Some(Fill::Solid(c)) = super::fill_from_desc(b"SoCo", &soco) else { panic!("solid fill") }; + assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]), "{:?}", c.to_rgb()); + // Missing components are rejected, not defaulted. + assert!(super::color_from_desc(&Descriptor::new("HSBC").with("Strt", Value::Double(50.0))).is_none()); + } + #[test] fn blending_ranges_map_to_blend_if() { use photocraft_doc::{BlendIf, BlendRange}; @@ -888,8 +1057,6 @@ mod tests { assert_eq!(super::brst_data(0), None); } - use super::*; - #[test] fn placed_layer_warp_is_read() { use photocraft_geom::warp::WarpStyle; @@ -1212,6 +1379,117 @@ mod more_tests { data } + /// `SoLd` data with a split (quilt) custom warp, as Photoshop writes it: a `quiltWarp` + /// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries + /// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it. + fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec { + use photocraft_psd::descriptor::ObjectArray; + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = || { + Descriptor::new("Rctn") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(25.0)) + .with("Rght", Value::Double(50.0)) + }; + let slices = |k: &str, v: &[f64]| { + let body = Descriptor::new("null").with(k, Value::UnitFloats { unit: *b"#Pxl", values: v.to_vec() }); + Value::ObjectArray(ObjectArray { prefix: v.len() as u32, body }) + }; + let mesh = Descriptor::new("null") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() }); + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", sx)) + .with("quiltSliceY", slices("quiltSliceY", sy)) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh })); + let quilt = Descriptor::new("quiltWarp") + .with("warpStyle", e("warpStyle", "warpCustom")) + .with("bounds", Value::Descriptor(bounds())) + .with("deformNumRows", Value::Integer(rows as i32)) + .with("deformNumCols", Value::Double(cols)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)); + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(trnf.map(Value::Double).to_vec())) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + if plain_warp { + d = d.with( + "warp", + Value::Descriptor(Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpNone")).with("bounds", Value::Descriptor(bounds()))), + ); + } + d = d.with("quiltWarp", Value::Descriptor(quilt)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A 3 × 2 patch identity grid over the 50 × 25 source with one interior control point pulled. + fn quilt_points() -> Vec<[f64; 2]> { + let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2).points; + if let Some(p) = pts.get_mut(10 * 3 + 4) { + p[1] += 3.0; + } + pts + } + + /// Photoshop's split warp (`quiltWarp`, mockups' usual warp) reads as a multi-patch mesh: the + /// patch boundaries become the knots, the control points stay in order, and the quilt wins over + /// the `warpNone` `warp` key written next to it. Before, any mesh but 4 × 4 was dropped and the + /// layer re-rendered flat. + #[test] + fn quilt_warp_is_read_as_a_multi_patch_mesh() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + // Photoshop pads the outer boundaries a little past the grid's edges. + let data = sold_with_quilt(quad, 10.0, 7.0, &[-0.5, 12.5, 37.5, 50.5], &[-0.5, 12.5, 25.5], &pts, true); + let warp = parse_placed_warp(b"SoLd", &data).expect("a quilt warp"); + let mesh = warp.mesh.as_ref().expect("its mesh"); + assert_eq!(mesh.points, pts); + assert_eq!(mesh.us.len(), 4); + assert_eq!(mesh.vs.len(), 3); + assert_eq!((mesh.us[0], mesh.us[3], mesh.vs[0], mesh.vs[2]), (0.0, 1.0, 0.0, 1.0)); + assert!((mesh.us[1] - 13.0 / 51.0).abs() < 1e-12 && (mesh.vs[1] - 13.0 / 26.0).abs() < 1e-12); + assert!(mesh.is_valid()); + } + + /// A quilt's control points frame the placed quad as a single patch's do (#3677): a grid + /// spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. + #[test] + fn quilt_warp_control_box_frames_the_quad() { + let mut pts = quilt_points(); + pts[0] = [-10.0, -5.0]; + if let Some(last) = pts.last_mut() { + *last = [60.0, 25.0]; + } + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let data = sold_with_quilt(quad, 10.0, 7.0, &[0.0, 12.5, 37.5, 50.0], &[0.0, 12.5, 25.0], &pts, false); + assert_eq!(parse_smart(b"SoLd", &data).1.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]); + } + + /// An inconsistent quilt reads as no warp rather than a wrong one, and never panics. + #[test] + fn inconsistent_quilt_warps_are_not_read() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + let (sx, sy) = ([0.0, 12.5, 37.5, 50.0], [0.0, 12.5, 25.0]); + let read = + |cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]]| parse_placed_warp(b"SoLd", &sold_with_quilt(quad, cols, rows, sx, sy, pts, false)); + assert!(read(10.0, 7.0, &sx, &sy, &pts).is_some()); + assert!(read(9.0, 7.0, &sx, &sy, &pts).is_none(), "not three points per patch side plus one"); + assert!(read(13.0, 7.0, &sx, &sy, &pts).is_none(), "more points claimed than stored"); + assert!(read(10.0, 7.0, &[0.0, 12.5, 50.0], &sy, &pts).is_none(), "one slice per patch boundary"); + assert!(read(10.0, 7.0, &[0.0, 37.5, 12.5, 50.0], &sy, &pts).is_none(), "slices must increase"); + assert!(read(10.0, 7.0, &[0.0, f64::NAN, 37.5, 50.0], &sy, &pts).is_none()); + assert!(read(1e9, 7.0, &sx, &sy, &pts).is_none(), "an absurd grid is refused before any arithmetic"); + assert!(read(10.0, 7.0, &sx, &sy, &pts[..pts.len() - 1]).is_none()); + let mut nan = pts.clone(); + nan[3] = [f64::NAN, 0.0]; + assert!(read(10.0, 7.0, &sx, &sy, &nan).is_none()); + } + /// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the /// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. #[test] diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index bafde26f30..74e42feb11 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -111,7 +111,7 @@ pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option { } /// HSB (hue in degrees, saturation and brightness 0..1) → RGB. -fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { +pub(crate) fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 }; let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); let f = h - h.floor(); diff --git a/crates/io/src/comps_map.rs b/crates/io/src/comps_map.rs index 8d0c78f3b0..3348061b34 100644 --- a/crates/io/src/comps_map.rs +++ b/crates/io/src/comps_map.rs @@ -12,6 +12,8 @@ //! Both are re-emitted verbatim while they still decode to the document's state, and are //! regenerated otherwise (comp appearance is not written to PSD; `.pcraft` keeps it). +use std::collections::HashMap; + use photocraft_doc::comps::layer_position; use photocraft_doc::{Artboard, ArtboardBackground, CompLayerState, Document, Guides, Layer, LayerComp}; use photocraft_geom::Rect; @@ -203,6 +205,14 @@ pub(crate) fn decode_comps(resource: Option<&[u8]>, doc: &Document) -> (Vec = HashMap::with_capacity(comps.len()); + for (i, c) in comps.iter().enumerate() { + id_to_index.entry(c.id).or_insert(i); + } let mut last = LayerComp { id: 0, name: "Last Document State".into(), @@ -215,33 +225,37 @@ pub(crate) fn decode_comps(resource: Option<&[u8]>, doc: &Document) -> (Vec { - let p = offset_position(h, o); - if p.is_none() { - warnings.push(format!( - "layer \"{}\": layer comp position offset ({}, {}) is out of range; that position was not imported", - l.name, o.0, o.1 - )); - } - p + let position = match (here, s.offset) { + (Some(h), Some(o)) => { + let p = offset_position(h, o); + if p.is_none() { + warnings.push(format!( + "layer \"{}\": layer comp position offset ({}, {}) is out of range; that position was not imported", + l.name, o.0, o.1 + )); } - _ => None, - }, - appearance: None, + p + } + _ => None, }; for id in &s.comps { - let target = if *id == 0 { Some(&mut last) } else { comps.iter_mut().find(|c| c.id == *id) }; - if let Some(c) = target { - c.states.push(state.clone()); + let target = if *id == 0 { Some(&mut last) } else { id_to_index.get(id).map(|&i| &mut comps[i]) }; + let Some(c) = target else { continue }; + let visible = if *id != 0 && c.apply_visibility { Some(resolved) } else { s.visible }; + if visible.is_none() && s.offset.is_none() { + continue; } + c.states.push(CompLayerState { layer: l.id, visible, position, appearance: None }); } } } @@ -406,6 +420,72 @@ mod tests { assert!(only.is_empty()); } + /// #8093: Photoshop writes `enab` only where a layer's visibility changes between comps. An + /// entry without it has the last `enab` before it, or visible when none came before: a layer + /// added after comp 1 and shown in it on Recapture is visible there, and comp 3, saved in the + /// same state as comp 2, hides the layer comp 2 hides. + #[test] + fn a_comp_entry_without_enab_inherits_the_previous_visibility() { + let mut d = Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let comp = |id: u32, name: &str| LayerComp { + id, + name: name.into(), + comment: String::new(), + apply_visibility: true, + apply_position: true, + apply_appearance: false, + states: Vec::new(), + }; + // c4 records positions only: it never takes a visibility. + let mut c4 = comp(4, "c4"); + c4.apply_visibility = false; + d.layer_comps = vec![comp(1, "c1"), comp(2, "c2"), comp(3, "c3"), c4]; + let entry = |ids: &[u32], enab: Option| { + let mut e = obj("null"); + if let Some(v) = enab { + e = e.with("enab", Value::Boolean(v)); + } + Value::Descriptor(e.with("compList", Value::List(ids.iter().map(|i| Value::Integer(*i as i32)).collect()))) + }; + let attach = |l: &mut Layer, settings: Vec| { + let cmls = VersionedDescriptor::new(Descriptor::new("null").with("layerSettings", Value::List(settings))).to_bytes(); + let mut raw = vec![(*b"shmd", write_shmd(&[]))]; + set_cmls(&mut raw, Some(cmls)); + l.psd_blocks = raw.iter().map(|(k, v)| (*k, std::sync::Arc::new(v.clone()))).collect(); + }; + // Layer A: [c1 {}], [c2 {enab false}], [c3 {}], [0 {}] (repro_comp_inherit.psd). + let mut a = Layer::raster("A", PixelFormat::RGBA8); + attach(&mut a, vec![entry(&[1], None), entry(&[2], Some(false)), entry(&[3], None), entry(&[0], None)]); + // Layer C: [c1 {}], [0 {enab false}] (repro_comp_new_layer.psd). + let mut c = Layer::raster("C", PixelFormat::RGBA8); + attach(&mut c, vec![entry(&[1], None), entry(&[0], Some(false))]); + // Layer E: [c1 {enab false}], [c4 {Ofst}], [c3 + 0 {}]: c4 keeps no visibility, c3 inherits + // hidden, and the Last Document State sharing c3's entry records nothing. + let mut e = Layer::raster("E", PixelFormat::RGBA8); + e.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + let moved = obj("null") + .with("compList", Value::List(vec![Value::Integer(4)])) + .with("Ofst", Value::Descriptor(obj("null").with("Hrzn", Value::Integer(2)).with("Vrtc", Value::Integer(3)))); + attach(&mut e, vec![entry(&[1], Some(false)), Value::Descriptor(moved), entry(&[3, 0], None)]); + let (a_id, c_id, e_id) = (a.id, c.id, e.id); + d.layers.push(a); + d.layers.push(c); + d.layers.push(e); + let res = write_comps_resource(&d).unwrap(); + let (comps, _, last) = comps_from_psd(Some(&res), &d); + let visible = |id: u32, layer| comps.iter().find(|c| c.id == id).and_then(|c| c.state(layer)).and_then(|s| s.visible); + assert_eq!([visible(1, a_id), visible(2, a_id), visible(3, a_id)], [Some(true), Some(false), Some(false)]); + assert_eq!(visible(1, c_id), Some(true), "no enab before it: visible"); + // The Last Document State entry keeps exactly what it records. + let last = last.unwrap(); + assert_eq!(last.state(c_id).and_then(|s| s.visible), Some(false)); + assert_eq!(last.state(a_id), None); + let c4 = comps.iter().find(|c| c.id == 4).and_then(|c| c.state(e_id)).unwrap(); + assert_eq!((c4.visible, c4.position), (None, Some((6, 7))), "a comp without visibility takes none"); + assert_eq!(visible(3, e_id), Some(false), "inherited from c1, past c4"); + assert_eq!(last.state(e_id), None, "the shared entry gives the Last Document State nothing"); + } + /// A layer at origin (4, 4) whose `shmd` carries a `cmls` item with one `layerSettings` /// entry for comp 1: visible, offset by `ofst`. fn doc_with_cmls_offset(ofst: (Value, Value)) -> (Document, photocraft_doc::LayerId, Vec) { diff --git a/crates/io/src/displace_map.rs b/crates/io/src/displace_map.rs index 8993663cc0..48b34c4b01 100644 --- a/crates/io/src/displace_map.rs +++ b/crates/io/src/displace_map.rs @@ -38,6 +38,10 @@ const HEADER: usize = 10; const MODE_TWO_PLANES: u16 = 3; /// 8K × 8K: far beyond the observed document-sized maps, and bounds the f32 map we build. const MAX_MAP_PIXELS: usize = 1 << 26; +/// 4K × 4K: the largest map PhotoCraft embeds itself. Params hold it as hex, about 4 bytes per +/// pixel, copied with every history state and written into `.pcraft`'s manifest, so a few of the +/// largest readable maps would make a file the reader refuses. +const MAX_EMBED_PIXELS: usize = 1 << 24; /// The template is the item plus the map; a little room for the rest of the descriptor. const MAX_TEMPLATE_BYTES: usize = 2 * MAX_MAP_PIXELS + (1 << 20); @@ -156,6 +160,69 @@ pub(super) fn export_item(params: &J) -> Result { Ok(item) } +/// `filter.distort.displace` params with `map` embedded the way Photoshop embeds a Displace +/// smart filter's map: a `filterFX` item ([`TEMPLATE_KEY`]) holding it as two 8-bit planes. A +/// smart filter re-renders from its params alone, so a map read from a file or an open document +/// must travel with them (#7423); embedded, it also saves to `.pcraft` and PSD like an imported +/// one. `map`'s first channel is the horizontal map and its second the vertical one (the first +/// again for one or two channels), as the filter reads them. `mapPath`, `mapDocument` and +/// `mapLayer` are dropped: `map` was already read from them (from that layer, when one was +/// named), and the embedded map replaces them. `None` for params that are not an +/// object, or an empty, short or oversized map. +pub fn embed_map(params: &J, map: &Image) -> Option { + let (w, h) = (map.rect.width(), map.rect.height()); + let n = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?; + if n == 0 || n > MAX_EMBED_PIXELS || map.ch == 0 || map.data.len() < n.checked_mul(map.ch)? { + return None; + } + let vertical = if map.ch >= 3 { 1 } else { 0 }; + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let pixels = || map.data.chunks_exact(map.ch).take(n); + let mut planes = Vec::with_capacity(HEADER + 2 * n); + planes.extend(h.to_le_bytes()); + planes.extend(w.to_le_bytes()); + planes.extend(MODE_TWO_PLANES.to_le_bytes()); + planes.extend(pixels().map(|px| px.first().map_or(0, |v| byte(*v)))); + planes.extend(pixels().map(|px| px.get(vertical).map_or(0, |v| byte(*v)))); + let enumerated = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + // The settings here are placeholders: the params' own replace them when the filter renders + // and when it is written to PSD (`export_item`). + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("Displace..."))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with( + "Fltr", + Value::Descriptor( + Descriptor::new(CLASS) + .with("HrzS", Value::Integer(10)) + .with("VrtS", Value::Integer(10)) + .with("DspM", enumerated("DspM", "StrF")) + .with("UndA", enumerated("UndA", "RptE")) + .with("EmbF", Value::Boolean(true)) + .with(MAP_KEY, Value::RawData(planes)), + ), + ) + .with("filterID", Value::Integer(FILTER_ID)); + let mut out = params.as_object()?.clone(); + for key in ["mapPath", "mapDocument", "mapLayer"] { + out.remove(key); + } + out.insert(TEMPLATE_KEY.into(), json!(to_hex(&item.to_bytes()))); + Some(J::Object(out)) +} + +/// `params` without the embedded map, for a Displace that names another one to read instead. +pub fn without_map(params: &J) -> J { + let mut out = params.clone(); + if let J::Object(m) = &mut out { + m.remove(TEMPLATE_KEY); + } + out +} + /// The displacement map embedded in Displace params imported from PSD (`None` when there is /// none or it is unreadable). The two planes become the first two channels; identical planes /// (every file seen so far) become one channel, which Displace reads for both directions. @@ -340,4 +407,38 @@ mod tests { } assert!(embedded_map(&json!({TEMPLATE_KEY: "0".repeat(MAX_TEMPLATE_BYTES * 2 + 2)})).is_none()); } + + /// #7423: a map from a file or an open document, embedded for a smart filter, is read back as + /// the filter's map and written to PSD like an imported one. + #[test] + fn an_embedded_map_reads_back_and_exports() { + let rect = Rect::new(0, 0, 3, 2); + // RGBA: red is the horizontal map, green the vertical one. + let rgba = Image { rect, ch: 4, data: (0..6).flat_map(|i| [i as f32 / 5.0, 1.0 - i as f32 / 5.0, 0.0, 1.0]).collect() }; + let params = json!({"horizontal": 20, "vertical": -5, "fit": "tile", "undefinedAreas": "wrap", "mapDocument": 0, "mapLayer": 3}); + let embedded = embed_map(¶ms, &rgba).unwrap(); + for key in ["mapPath", "mapDocument", "mapLayer"] { + assert!(embedded.get(key).is_none(), "{key}"); + } + assert_eq!(embedded["horizontal"], json!(20)); + let map = embedded_map(&embedded).unwrap(); + assert_eq!((map.rect, map.ch), (rect, 3)); + let h: Vec = map.data.chunks(3).map(|p| p[0]).collect(); + let v: Vec = map.data.chunks(3).map(|p| p[1]).collect(); + assert_eq!(h, [0.0, 51.0 / 255.0, 102.0 / 255.0, 153.0 / 255.0, 204.0 / 255.0, 1.0]); + assert_eq!(v, [1.0, 204.0 / 255.0, 153.0 / 255.0, 102.0 / 255.0, 51.0 / 255.0, 0.0]); + // Written to PSD with the params' own settings. + let f = export_item(&embedded).unwrap(); + let fltr = get_desc(&f, "Fltr").unwrap(); + assert_eq!(num(fltr.get("HrzS")), Some(20.0)); + assert_eq!(enum_of(fltr, "DspM"), Some(&b"Tile"[..])); + // A grey map displaces the same both ways. + let grey = Image { rect, ch: 1, data: vec![0.25; 6] }; + let map = embedded_map(&embed_map(&json!({}), &grey).unwrap()).unwrap(); + assert_eq!(map.ch, 1); + // Nothing to embed. + assert!(embed_map(&json!({}), &Image { rect: Rect::new(0, 0, 0, 0), ch: 4, data: vec![] }).is_none()); + assert!(embed_map(&json!({}), &Image { rect, ch: 4, data: vec![0.0; 5] }).is_none(), "short data"); + assert!(embed_map(&json!(3), &grey).is_none(), "params not an object"); + } } diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs new file mode 100644 index 0000000000..37e7daf214 --- /dev/null +++ b/crates/io/src/doc_path.rs @@ -0,0 +1,59 @@ +//! Files named by paths stored in a document: linked smart objects, the Displace map and Color +//! Lookup file of a smart filter, data-set images, video footage. +//! +//! Such a path is followed only through [`resolve`]: a relative path is taken from the document's +//! folder, and only local files, or files on the network share the document itself is on, are +//! opened (see `craft_docpath`). A refused path is treated like a missing file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is not a local file, or is not on the document's own share. + Refused(Refused), + /// The path is relative and the document has no file to take it from. + NoDocumentFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoDocumentFolder => f.write_str("the path is relative and the document has not been saved"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to open for `stored`, a path or `file:` URL stored in a document, or why it is not +/// followed. `document` is the path of the document it is stored in, if the document has a file; +/// a relative `document` is taken from the working directory, where it was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result { + let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); + let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NoDocumentFolder); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder() { + let doc = std::env::temp_dir().join("art").join("poster.pcraft"); + assert_eq!(resolve("logo.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("logo.png"))); + assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network))); + } +} diff --git a/crates/io/src/duotone_map.rs b/crates/io/src/duotone_map.rs new file mode 100644 index 0000000000..693264adec --- /dev/null +++ b/crates/io/src/duotone_map.rs @@ -0,0 +1,293 @@ +//! Duotone inks ⇄ a PSD's Color Mode Data in Duotone mode (colour mode 8). +//! +//! The PSD specification leaves this block undocumented. Photoshop writes 524 bytes (observed on +//! files it made with 1–4 inks): +//! +//! | offset | size | content | +//! |---|---|---| +//! | 0 | 2 | version, 1 | +//! | 2 | 2 | number of inks, 1–4 | +//! | 4 | 4 × 10 | ink colours: colour space + 4 × u16, as in swatch files; space 0xFFFF = no ink | +//! | 44 | 4 × 64 | ink names, Pascal strings | +//! | 300 | 4 × 28 | ink curves: 13 × i16 then an i16 (0), the PSD's transfer-function layout | +//! | 412 | 2 | dot gain, 20 | +//! | 414 | 11 × 10 | overprint colours, space 0xFFFF = computed | +//! +//! A curve's 13 values are the ink's density in tenths of a percent at gray densities of 0, 5, +//! 10, 20, …, 90, 95 and 100 %; −1 leaves a point out (Photoshop's empty dialog fields), except +//! at 0 and 100 %: Photoshop refuses to open a file with −1 there. +//! +//! A colour-book ink (PANTONE, …) is stored by its book key, not a colour. Photoshop also writes +//! every ink's Lab equivalent in image resource 1066 (Alternate Duotone Colors): version 1, a +//! count, then one 10-byte colour per ink in the same order. + +use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::{Duotone, DuotoneInk}; +use photocraft_psd::aco::AcoColor; + +/// Length of the block Photoshop writes. +pub(crate) const BLOCK_LEN: usize = 524; +/// Gray densities (%) of a curve's 13 points. +const CURVE_AT: [u16; 13] = [0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]; +const NO_COLOR: [u8; 10] = [0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; +const NAMES_AT: usize = 44; +const CURVES_AT: usize = 300; +const DOT_GAIN_AT: usize = 412; +/// Image resource: the inks' Lab equivalents. +pub(crate) const ALTERNATE_DUOTONE_COLORS: u16 = 1066; + +fn u16_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) +} + +/// The sRGB display colour of the 10-byte colour structure at `at`; `None` for a colour book or +/// another space we can't show. +fn display(b: &[u8], at: usize) -> Option<[f32; 3]> { + let space = u16_at(b, at)?; + let values = [u16_at(b, at + 2)?, u16_at(b, at + 4)?, u16_at(b, at + 6)?, u16_at(b, at + 8)?]; + let c = crate::channel_map::decode_known_color(space, values)?; + Some(c.to_rgb().map(|v| v.clamp(0.0, 1.0))) +} + +/// The inks of a Duotone block, or `None` when it is not one this reads (the caller keeps it raw). +/// `alternate` is resource 1066, which gives a colour-book ink its colour. `unknown` is set when +/// an ink's colour can't be shown even so; it is then black here, and the caller shows the +/// grey plate instead. +pub(crate) fn parse(b: &[u8], alternate: Option<&[u8]>, unknown: &mut bool) -> Option> { + if b.len() < DOT_GAIN_AT || u16_at(b, 0)? != 1 { + return None; + } + let n = usize::from(u16_at(b, 2)?); + if !(1..=4).contains(&n) { + return None; + } + (0..n) + .map(|i| { + let alt = alternate.filter(|a| u16_at(a, 0) == Some(1) && u16_at(a, 2).is_some_and(|k| usize::from(k) > i)); + let color = display(b, 4 + 10 * i).or_else(|| display(alt?, 4 + 10 * i)).unwrap_or_else(|| { + *unknown = true; + [0.0; 3] + }); + let name_at = NAMES_AT + 64 * i; + let len = usize::from(*b.get(name_at)?).min(63); + let name = String::from_utf8_lossy(b.get(name_at + 1..name_at + 1 + len)?).into_owned(); + let curve_at = CURVES_AT + 28 * i; + let mut curve: Vec = Vec::new(); + for (k, x) in CURVE_AT.iter().enumerate() { + let v = u16_at(b, curve_at + 2 * k)? as i16; + if v >= 0 { + curve.push(CurvePoint { input: f32::from(*x) / 100.0, output: (f32::from(v) / 1000.0).clamp(0.0, 1.0) }); + } + } + if curve.len() < 2 { + curve = DuotoneInk::new("", [0.0; 3]).curve; + } + Some(DuotoneInk { name, color, curve }) + }) + .collect() +} + +/// The Duotone block for `d`'s first four inks: RGB ink colours, their names and their curves +/// sampled at the 13 gray densities (the points on that grid as they are, the others left out), +/// Photoshop's default dot gain and computed overprint colours. +pub(crate) fn write(d: &Duotone) -> Vec { + let mut b = Vec::with_capacity(BLOCK_LEN); + let inks: Vec<&DuotoneInk> = d.inks.iter().take(4).collect(); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&(inks.len().max(1) as u16).to_be_bytes()); + for i in 0..4 { + match inks.get(i) { + Some(ink) => { + let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16; + let (space, v) = AcoColor::Rgb(ink.color.map(q)).to_record(); + b.extend_from_slice(&space.to_be_bytes()); + v.iter().for_each(|x| b.extend_from_slice(&x.to_be_bytes())); + } + None => b.extend_from_slice(&NO_COLOR), + } + } + for i in 0..4 { + let mut name = [0u8; 64]; + if let Some(ink) = inks.get(i) { + let bytes: Vec = ink.name.chars().map(|c| if c.is_ascii() { c as u8 } else { b'?' }).take(63).collect(); + name[0] = bytes.len() as u8; + name[1..=bytes.len()].copy_from_slice(&bytes); + } + b.extend_from_slice(&name); + } + for i in 0..4 { + let values = curve_values(inks.get(i).map(|ink| ink.curve.as_slice())); + values.iter().for_each(|v| b.extend_from_slice(&v.to_be_bytes())); + b.extend_from_slice(&0i16.to_be_bytes()); + } + b.extend_from_slice(&20u16.to_be_bytes()); + for _ in 0..11 { + b.extend_from_slice(&NO_COLOR); + } + b +} + +/// A curve's 13 values: an identity curve (or none) is 0 and 100 % only, as Photoshop writes it; +/// a curve whose points all lie on the 13 gray densities keeps exactly those points; any other +/// curve is sampled at all 13. +fn curve_values(curve: Option<&[CurvePoint]>) -> [i16; 13] { + let mut out = [-1i16; 13]; + let pts = curve.filter(|c| c.len() >= 2); + let identity = pts.is_none_or(|c| c.iter().all(|p| (p.input - p.output).abs() < 1e-4)); + let per_mille = |v: f32| (v.clamp(0.0, 1.0) * 1000.0).round() as i16; + if identity { + out[0] = 0; + out[12] = 1000; + return out; + } + let pts = pts.unwrap_or_default(); + let slot = |x: f32| CURVE_AT.iter().position(|a| (f32::from(*a) / 100.0 - x).abs() < 1e-4); + let ink = DuotoneInk { name: String::new(), color: [0.0; 3], curve: pts.to_vec() }; + if pts.iter().all(|p| slot(p.input).is_some()) { + for p in pts { + if let Some(k) = slot(p.input) { + out[k] = per_mille(p.output); + } + } + // Photoshop needs both ends. + for k in [0, 12] { + if out[k] < 0 { + out[k] = per_mille(ink.density(f32::from(CURVE_AT[k]) / 100.0)); + } + } + return out; + } + for (k, x) in CURVE_AT.iter().enumerate() { + out[k] = per_mille(ink.density(f32::from(*x) / 100.0)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ink(name: &str, color: [f32; 3], curve: &[(f32, f32)]) -> DuotoneInk { + let curve = + if curve.is_empty() { DuotoneInk::new("", [0.0; 3]).curve } else { curve.iter().map(|&(input, output)| CurvePoint { input, output }).collect() }; + DuotoneInk { name: name.into(), color, curve } + } + + #[test] + fn writes_the_block_as_photoshop_does() { + // Black and orange (255, 128, 0) with plain curves: the block Photoshop 2025 wrote for the + // same inks, field by field. + let d = Duotone { inks: vec![ink("Black", [0.0; 3], &[]), ink("Orange", [1.0, 32895.0 / 65535.0, 0.0], &[])], psd_raw: None }; + let b = write(&d); + assert_eq!(b.len(), BLOCK_LEN); + assert_eq!(&b[..4], &[0, 1, 0, 2]); + assert_eq!(&b[4..14], &[0; 10], "black, RGB"); + assert_eq!(&b[14..24], &[0, 0, 0xFF, 0xFF, 0x80, 0x7F, 0, 0, 0, 0], "orange, RGB"); + assert_eq!(&b[24..44], &[NO_COLOR, NO_COLOR].concat(), "no third and fourth ink"); + assert_eq!(&b[44..50], b"\x05Black"); + assert_eq!(&b[108..115], b"\x06Orange"); + let identity: Vec = [0i16, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1000, 0].iter().flat_map(|v| v.to_be_bytes()).collect(); + for i in 0..4 { + assert_eq!(&b[300 + 28 * i..328 + 28 * i], &identity[..], "curve {i}"); + } + assert_eq!(&b[412..414], &[0, 20], "dot gain"); + assert!(b[414..].chunks(10).all(|c| c == NO_COLOR), "overprint colours computed"); + } + + #[test] + fn inks_round_trip() { + let d = Duotone { + inks: vec![ + ink("Black", [0.0; 3], &[(0.0, 0.0), (0.6, 0.0), (1.0, 1.0)]), + ink("Teal", [0.0, 0.5, 0.5], &[(0.0, 0.1), (0.05, 0.2), (0.95, 0.9), (1.0, 0.95)]), + ink("Tan", [0.8, 0.7, 0.5], &[]), + ], + psd_raw: None, + }; + let mut unknown = false; + let back = parse(&write(&d), None, &mut unknown).unwrap(); + assert!(!unknown); + assert_eq!(back.len(), 3); + for (a, b) in d.inks.iter().zip(&back) { + assert_eq!(a.name, b.name); + assert!(a.color.iter().zip(b.color).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} {b:?}"); + assert_eq!(a.curve.len(), b.curve.len(), "{}", a.name); + for (p, q) in a.curve.iter().zip(&b.curve) { + assert!((p.input - q.input).abs() < 1e-6 && (p.output - q.output).abs() < 1e-3, "{}: {p:?} {q:?}", a.name); + } + } + } + + #[test] + fn a_curve_off_the_grid_is_sampled_at_all_13_points() { + let curve = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.33, output: 0.66 }, CurvePoint { input: 1.0, output: 1.0 }]; + let v = curve_values(Some(&curve)); + assert!(v.iter().all(|&x| x >= 0), "{v:?}"); + assert_eq!((v[0], v[6], v[12]), (0, 746, 1000), "{v:?}"); + } + + /// Black (CMYK) and PANTONE 327 CVC as Photoshop writes them (psd-tools' + /// `colormodes/4x4_8bit_duotone.psd`): the book ink is stored by its key, " 327C". + fn black_and_pantone() -> Vec { + let mut b = write(&Duotone { inks: vec![ink("Black", [0.0; 3], &[]), ink("PANTONE 327 CVC", [0.0; 3], &[])], psd_raw: None }); + b[4..14].copy_from_slice(&[0, 2, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + b[14..24].copy_from_slice(&[0, 3, 0x20, 0x20, 0x33, 0x32, 0x37, 0x43, 0, 0]); + b + } + + #[test] + fn a_colour_book_ink_without_its_lab_equivalent_is_unknown() { + let mut unknown = false; + let inks = parse(&black_and_pantone(), None, &mut unknown).unwrap(); + assert!(unknown); + assert_eq!((inks[1].name.as_str(), inks[1].color), ("PANTONE 327 CVC", [0.0; 3])); + assert!(inks[0].color.iter().all(|&c| c < 0.2), "CMYK black: {:?}", inks[0].color); + } + + #[test] + fn a_colour_book_ink_takes_its_colour_from_resource_1066() { + // Photoshop's resource 1066 of the same file: Lab (9.53, 4.98, 4.25) and (45.49, -66, -5). + let alternate = [0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0, 1, 0]; + let mut unknown = false; + let inks = parse(&black_and_pantone(), Some(&alternate), &mut unknown).unwrap(); + assert!(!unknown); + let [r, g, b] = inks[1].color; + assert!(r < 0.1 && g > 0.4 && b > 0.35 && g > b, "PANTONE 327 is teal: {:?}", inks[1].color); + // A short or foreign resource gives nothing. + let mut unknown = false; + parse(&black_and_pantone(), Some(&alternate[..14]), &mut unknown).unwrap(); + assert!(unknown); + } + + #[test] + fn an_hsb_ink_is_shown() { + let mut b = write(&Duotone { inks: vec![ink("Green", [0.0; 3], &[])], psd_raw: None }); + // Hue 120°, full saturation and brightness. + b[4..14].copy_from_slice(&[0, 1, 0x55, 0x55, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + let mut unknown = false; + let inks = parse(&b, None, &mut unknown).unwrap(); + assert!(!unknown); + assert!(inks[0].color[1] > 0.99 && inks[0].color[0] < 0.01 && inks[0].color[2] < 0.01, "{:?}", inks[0].color); + } + + #[test] + fn a_curve_always_has_both_ends() { + // On the grid, but without a 0 % or a 100 % point: Photoshop won't open −1 there. + let curve = [CurvePoint { input: 0.2, output: 0.1 }, CurvePoint { input: 0.5, output: 0.7 }]; + let v = curve_values(Some(&curve)); + assert_eq!(v, [100, -1, -1, 100, -1, -1, 700, -1, -1, -1, -1, -1, 700]); + } + + #[test] + fn malformed_blocks_are_not_read() { + let mut unknown = false; + let good = write(&Duotone { inks: vec![ink("Black", [0.0; 3], &[])], psd_raw: None }); + assert!(parse(&good[..100], None, &mut unknown).is_none()); + let mut v = good.clone(); + v[1] = 2; + assert!(parse(&v, None, &mut unknown).is_none(), "version"); + let mut n = good; + n[3] = 5; + assert!(parse(&n, None, &mut unknown).is_none(), "ink count"); + } +} diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..45e0d7469e 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -213,6 +213,7 @@ fn parse_stroke(d: &Descriptor) -> StrokeFx { _ => StrokePosition::Outside, }, paint, + overprint: bool_of(d, "overprint"), } } @@ -239,6 +240,7 @@ fn parse_bevel(d: &Descriptor) -> Bevel { altitude: f(d, "Lald", 30.0), use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))), gloss_contour: contour(d, "TrnS"), + gloss_anti_alias: bool_of(d, "antialiasGloss"), highlight: FxCommon { enabled: true, blend: blend_of(d, "hglM", BlendMode::Screen), opacity: pct(d, "hglO", 0.75) }, highlight_color: get_desc(d, "hglC").and_then(color_from_desc).unwrap_or(Color::WHITE), shadow: FxCommon { enabled: true, blend: blend_of(d, "sdwM", BlendMode::Multiply), opacity: pct(d, "sdwO", 0.75) }, @@ -404,7 +406,8 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { }; let mut d = with_common(Descriptor::new("FrFX"), &s.common) .with("Styl", Value::Enumerated { type_id: Id::new("FStl"), value: Id::new(styl) }) - .with("Sz ", unit(b"#Pxl", s.size)); + .with("Sz ", unit(b"#Pxl", s.size)) + .with("overprint", Value::Boolean(s.overprint)); d = match &s.paint { FxPaint::Color(c) => { d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }).with("Clr ", Value::Descriptor(color_to_desc(c))) @@ -482,6 +485,7 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { .with("blur", unit(b"#Pxl", b.size)) .with("bvlD", Value::Enumerated { type_id: Id::new("BESs"), value: Id::new(if b.up { "In " } else { "Out " }) }) .with("TrnS", contour_value(&b.gloss_contour)) + .with("antialiasGloss", Value::Boolean(b.gloss_anti_alias)) .with("Sftn", unit(b"#Pxl", b.soften)); let d = match &b.contour { Some(c) => d @@ -680,12 +684,14 @@ mod tests { size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)), + overprint: false, }), Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Multiply, 0.5), size: 6.0, position: StrokePosition::Center, paint: FxPaint::Gradient(g.clone()), + overprint: true, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, @@ -720,6 +726,7 @@ mod tests { altitude: 40.0, use_global_light: false, gloss_contour: Contour::Linear, + gloss_anti_alias: true, highlight: FxCommon::new(BlendMode::Screen, 0.75), highlight_color: Color::WHITE, shadow: FxCommon::new(BlendMode::Multiply, 0.6), @@ -736,6 +743,16 @@ mod tests { assert_eq!(write_lfx2(true, a), write_lfx2(true, b)); } + /// `approx` compares re-serialized bytes, so a field the writer drops looks equal on both + /// sides: read the Gloss Contour's Anti-aliased back directly. + #[test] + fn bevel_gloss_anti_alias_is_written() { + let fx: Vec = sample_effects().into_iter().filter(|e| matches!(e, Effect::BevelEmboss(_))).collect(); + assert!(matches!(fx.as_slice(), [Effect::BevelEmboss(b)] if b.gloss_anti_alias)); + let (_, back) = parse_lfx2(&write_lfx2(false, &fx)).unwrap(); + assert!(matches!(back.as_slice(), [Effect::BevelEmboss(b)] if b.gloss_anti_alias), "{back:?}"); + } + #[test] fn lfx2_roundtrip_all_kinds() { let fx = sample_effects(); @@ -748,6 +765,29 @@ mod tests { assert!(matches!(back.last().unwrap(), Effect::Stroke(_))); } + #[test] + fn stroke_overprint_is_read_and_written() { + for overprint in [false, true] { + let fx = [Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 3.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::BLACK), + overprint, + })]; + let (_, back) = parse_lfx2(&write_lfx2(false, &fx)).unwrap(); + let [Effect::Stroke(s)] = back.as_slice() else { panic!("{back:?}") }; + assert_eq!(s.overprint, overprint); + } + // A stroke without the key (other writers) has Overprint off. + let d = Descriptor::new("null").with("FrFX", Value::Descriptor(Descriptor::new("FrFX").with("enab", Value::Boolean(true)))); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor::new(d).to_bytes()); + let (_, back) = parse_lfx2(&data).unwrap(); + let [Effect::Stroke(s)] = back.as_slice() else { panic!("{back:?}") }; + assert!(!s.overprint); + } + #[test] fn lfx2_ignores_absent_effects_but_keeps_disabled_configured_effects() { let d = Descriptor::new("null") @@ -811,6 +851,84 @@ mod tests { } } + /// A Gradient Overlay `lfx2` laid out as Photoshop writes it: one black colour, opacity + /// 100 → 0 % with the opacity midpoint `mid` (%), Smoothness `intr` (0..4096) and the method + /// enum `gs99` (Photoshop's key in layer effects; codes `Gcls`, `Lnr `, `Perc`). + fn overlay_lfx2(mid: i32, intr: f64, method: &str) -> Vec { + let black = || Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + let clr = |loc: i32| { + Value::Descriptor( + Descriptor::new("Clrt") + .with("Clr ", Value::Descriptor(black())) + .with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") }) + .with("Lctn", Value::Integer(loc)) + .with("Mdpn", Value::Integer(50)), + ) + }; + let trn = |loc: i32, op: f32, mid: i32| { + Value::Descriptor(Descriptor::new("TrnS").with("Opct", unit(b"#Prc", op)).with("Lctn", Value::Integer(loc)).with("Mdpn", Value::Integer(mid))) + }; + let grad = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("CstS") }) + .with("Intr", Value::Double(intr)) + .with("Clrs", Value::List(vec![clr(0), clr(4096)])) + .with("Trns", Value::List(vec![trn(0, 100.0, 50), trn(4096, 0.0, mid)])); + let fx = Descriptor::new("GrFl") + .with("enab", Value::Boolean(true)) + .with("Grad", Value::Descriptor(grad)) + .with("Angl", unit(b"#Ang", 0.0)) + .with("gs99", Value::Enumerated { type_id: Id::new("gradientInterpolationMethodType"), value: Id::new(method) }); + let d = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with("GrFl", Value::Descriptor(fx)); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes()); + data + } + + fn overlay_opacity(data: &[u8]) -> Vec<(f32, f32)> { + let (_, fx) = parse_lfx2(data).unwrap(); + let Some(Effect::GradientOverlay { gradient, .. }) = fx.first() else { panic!("overlay: {fx:?}") }; + gradient.opacity_stops.clone() + } + + fn opacity_at(o: &[(f32, f32)], t: f32) -> f32 { + let i = o.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + o[i].1 + (o[i + 1].1 - o[i].1) * (t - o[i].0) / (o[i + 1].0 - o[i].0) + } + + /// #1954: the overlay's transparency follows Smoothness, the opacity midpoints and the method + /// like a gradient fill layer's (Photoshop renders both identically; the values are its + /// composites at t = 0.25 and 0.5). + #[test] + fn gradient_overlay_opacity_follows_smoothness_midpoint_and_method() { + let at = |mid, intr, method: &str, t| opacity_at(&overlay_opacity(&overlay_lfx2(mid, intr, method)), t); + // Plain Classic stays the linear fade it is stored as. + assert_eq!(overlay_opacity(&overlay_lfx2(50, 0.0, "Gcls")), vec![(0.0, 1.0), (1.0, 0.0)]); + // Midpoint 25 %: half transparent a quarter of the way. + assert!((at(25, 0.0, "Gcls", 0.25) - 0.5).abs() < 0.01); + // Smoothness 100 % eases the ends (Photoshop: 0.796 at a quarter). + assert!((at(50, 4096.0, "Gcls", 0.25) - 0.796).abs() < 0.015); + // The method bends the transparency ramp too, not only the colours (Photoshop at the + // middle: 0.631 Linear, 0.255 Perceptual). + assert!((at(50, 0.0, "Lnr ", 0.5) - 0.631).abs() < 0.015); + assert!((at(50, 0.0, "Perc", 0.5) - 0.255).abs() < 0.015); + assert!((at(25, 4096.0, "Perc", 0.25) - 0.255).abs() < 0.015); + } + + /// A regenerated `lfx2` carries the baked ramp as plain (Classic, Smoothness 0) stops, so it + /// reads back unchanged. + #[test] + fn baked_overlay_opacity_round_trips() { + let data = overlay_lfx2(25, 4096.0, "Perc"); + let (master, fx) = parse_lfx2(&data).unwrap(); + let a = overlay_opacity(&data); + let b = overlay_opacity(&write_lfx2(master, &fx)); + assert!(a.len() > 2); + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(&b) { + assert!((x.0 - y.0).abs() < 1e-3 && (x.1 - y.1).abs() < 1e-4, "{x:?} vs {y:?}"); + } + } + #[test] fn garbage_is_none() { assert!(parse_lfx2(&[0, 0]).is_none()); diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index a18367467a..7786c28351 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -22,11 +22,12 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { return import_tiff_page(name, bytes, None); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; - // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop - // (#3802, #4804). + // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in + // Photoshop (#3802, #4804, #7886). A GIF is always palette-based: the first frame's table. let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), + Some(Format::Gif) => codecs::gif_palette(bytes), _ => None, }; let mut r = image_to_document(name, &img)?; @@ -51,14 +52,57 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { _ => None, }; } + if d.icc_profile.is_none() && d.mode == ColorMode::Rgb { + d.icc_profile = cicp_profile(&img); + if d.icc_profile.is_none() + && let Some((profile, warning)) = hdr_fallback(&img) + { + d.icc_profile = Some(profile); + r.warnings.push(warning); + } + } Ok(r) } +/// A PQ or HLG image, which the decoder turned into linear light, on primaries +/// [`cicp_profile`] has no profile for: left untagged it would be read as sRGB-encoded, so it is +/// tagged linear sRGB (its tones right, its primaries assumed) and the warning says so. +fn hdr_fallback(img: &Image) -> Option<(Arc>, String)> { + let c = img.cicp.filter(|c| c.is_hdr() && img.sample_type().is_float())?; + let warning = format!("the HDR colour primaries (H.273 code {}) aren't supported; the image is read as linear sRGB", c.primaries); + Some((photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(), warning)) +} + +/// The profile a HEIF's `nclx` colour description stands for, when the file has no ICC profile +/// (#6752): a BT.2020 or P3 image is tagged so, and a PQ or HLG one, which the decoder turned into +/// linear light, gets a linear profile on its primaries. BT.709 SDR stays untagged, read as sRGB +/// as before; BT.709 with a linear transfer (8) is tagged linear sRGB. +fn cicp_profile(img: &Image) -> Option>> { + let c = img.cicp?; + let linear = img.sample_type().is_float(); + if c.primaries == 1 && !linear && matches!(c.transfer, 1 | 2 | 6 | 13 | 14 | 15) { + return None; + } + // Unspecified primaries on an HDR image: BT.2100's, which PQ and HLG are defined with. + let primaries = if c.primaries == 2 && c.is_hdr() { 9 } else { c.primaries }; + photocraft_cms::builtin::cicp_rgb(primaries, c.transfer, linear).map(|p| p.to_bytes()) +} + /// Every frame of a GIF as a single-layer document, handed to `each` in order with its delay in /// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller /// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { - let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?; + import_frames(name, codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +/// Every frame of a WebP as a single-layer document, as [`import_gif_frames`] does for a GIF: an +/// animation's frames composited onto its canvas, with their durations in milliseconds; a still +/// WebP is one frame. +pub fn import_webp_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { + import_frames(name, codecs::decode_webp_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +fn import_frames(name: &str, frames: Vec, each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { let n = frames.len(); for f in frames { each(image_to_document(name, &f.image)?.document, f.delay_ms)?; @@ -67,8 +111,9 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document } /// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality -/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its -/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. +/// (estimated from its quantisation tables), a WebP's lossless or lossy encoding with its +/// quality, and a PNG's `sTER` stereo pairing. A lossy WebP whose quality can't be read gets +/// [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { file_encoding_as(codecs::detect(bytes), bytes) } @@ -78,6 +123,7 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil let mut e = photocraft_doc::FileEncoding::default(); match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), + Some(Format::Png) => e.png_stereo = codecs::png_stereo(bytes).map(codecs::PngStereo::mode), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), Some(codecs::WebpEncoding::Lossy { quality }) => { @@ -95,8 +141,8 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75; /// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a -/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or -/// lossless. +/// save that chose no quality writes, so a JPEG keeps its quality, a WebP stays lossy or lossless, +/// and a stereo PNG keeps its `sTER` pairing (#6063). pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { let e = &doc.file_encoding; if let Some(q) = e.jpeg_quality { @@ -108,6 +154,7 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { if let Some(q) = e.webp_quality { opts.webp_quality = q.min(100); } + opts.png_stereo = e.png_stereo.and_then(codecs::PngStereo::from_mode); } /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the @@ -241,6 +288,11 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; @@ -805,22 +857,41 @@ fn cmyk_image_to_srgb(img: &Image) -> Result { Ok(Image::from_normalized(w, h, layout, sample, &out)?.with_icc(Some(dst.to_bytes().to_vec())).with_meta(img.meta.clone())) } -/// Indexed Color → PNG-8 with its colour table; Duotone → the inks rendered as RGB. +/// Indexed Color → PNG-8 or an 8-bit BMP with its colour table; Duotone → the inks rendered as RGB. fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> Result, IoError> { match doc.mode { - ColorMode::Indexed if format == Format::Png => { + ColorMode::Indexed if matches!(format, Format::Png | Format::Bmp) => { let Some(table) = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256) else { return Ok(None) }; + // PNG transparency belongs to a palette index, not its RGB value. Keep the + // transparent entry out of opaque lookup, even when it duplicates an opaque colour. + let png_transparent_index = table.transparent.map(usize::from).filter(|_| format == Format::Png); + let (opaque_indices, colors): (Vec, Vec<_>) = + table.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != png_transparent_index).map(|(i, c)| (i as u8, c)).unzip(); + let opaque = photocraft_doc::ColorTable { colors, transparent: None }; let mut idx = try_buffer(doc.size.area() as usize, 1)?; - let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { - idx.extend(band.px.iter().map(|p| match table.transparent { - Some(t) if p[3] < 0.5 => t, - _ => table.nearest([p[0], p[1], p[2]]) as u8, - })); + photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), IoError> { + for p in &band.px { + let i = match table.transparent { + Some(t) if p[3] < 0.5 => t, + _ => *opaque_indices + .get(opaque.nearest([p[0], p[1], p[2]])) + .ok_or_else(|| IoError::Unsupported("Indexed palette has no opaque entry; add an opaque colour or convert to RGB".into()))?, + }; + idx.push(i); + } Ok(()) - }); + })?; + if format == Format::Bmp { + let bytes = codecs::encode_bmp_indexed(doc.size.width, doc.size.height, &idx, &table.colors)?; + let mut warnings = vec![format!("written as an 8-bit palette BMP ({} colours)", table.colors.len())]; + if let Some(t) = table.transparent { + warnings.push(format!("BMP has no transparency; transparent pixels are written in the colour of palette entry {t}")); + } + return Ok(Some(ExportResult { bytes, warnings })); + } // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); - let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; + let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi, opts.encode.png_stereo)?; let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())]; if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() { warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into()); @@ -843,6 +914,59 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> } } +#[cfg(test)] +mod cicp_tests { + use super::*; + use photocraft_cms::{Builtin, Profile}; + + fn tagged(sample: CSample, cicp: codecs::Cicp) -> Image { + let mut img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap(); + img.cicp = Some(cicp); + img + } + + fn description(p: Option>>) -> Option { + p.map(|b| Profile::parse(&b).unwrap().description) + } + + #[test] + fn hlg_bt2020_is_tagged_linear_bt2020() { + let hlg = codecs::Cicp { primaries: 9, transfer: codecs::Cicp::TRANSFER_HLG, matrix: 9, full_range: true }; + let d = description(cicp_profile(&tagged(CSample::F32, hlg))).unwrap(); + assert!(d.contains("BT.2020") && d.contains("linear"), "{d}"); + // Unspecified primaries on HLG are BT.2100's. + let d = description(cicp_profile(&tagged(CSample::F32, codecs::Cicp { primaries: 2, ..hlg }))).unwrap(); + assert!(d.contains("BT.2020"), "{d}"); + } + + #[test] + fn sdr_nclx_is_unchanged_or_tagged_with_its_builtin() { + // BT.709 / sRGB: untagged, as before. + for transfer in [1, 2, 13] { + assert_eq!(cicp_profile(&tagged(CSample::U16, codecs::Cicp { primaries: 1, transfer, matrix: 6, full_range: false })), None); + } + // BT.709 with a linear transfer: linear sRGB, not read as sRGB. + let linear = codecs::Cicp { primaries: 1, transfer: 8, matrix: 6, full_range: true }; + assert_eq!(description(cicp_profile(&tagged(CSample::U16, linear))).as_deref(), Some(Builtin::LinearSrgb.description())); + let p3 = codecs::Cicp { primaries: 12, transfer: 13, matrix: 6, full_range: true }; + assert_eq!(description(cicp_profile(&tagged(CSample::U8, p3))).as_deref(), Some(Builtin::DisplayP3.description())); + assert_eq!(cicp_profile(&Image::new(1, 1, ChannelLayout::Rgb, CSample::U8).unwrap()), None, "no nclx, no profile"); + } + + #[test] + fn hdr_on_unsupported_primaries_is_tagged_linear_srgb_with_a_warning() { + // DCI-P3 (11) primaries: no profile, but the linear pixels must not be read as sRGB. + let hlg = codecs::Cicp { primaries: 11, transfer: codecs::Cicp::TRANSFER_HLG, matrix: 9, full_range: true }; + let img = tagged(CSample::F32, hlg); + assert_eq!(cicp_profile(&img), None); + let (profile, warning) = hdr_fallback(&img).unwrap(); + assert_eq!(description(Some(profile)).as_deref(), Some(Builtin::LinearSrgb.description())); + assert!(warning.contains("11"), "{warning}"); + // SDR on the same primaries keeps its samples as decoded: no fallback. + assert!(hdr_fallback(&tagged(CSample::U16, codecs::Cicp { transfer: 13, ..hlg })).is_none()); + } +} + #[cfg(test)] mod cmyk_band_tests { use super::*; diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index 0d34816a79..6c7aaf32f4 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -185,10 +185,116 @@ pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method out } +/// Exponent of Photoshop's opacity response for Perceptual and Linear gradients: the opacity +/// stops enter the method's space as if they were sRGB grey levels (Linear: linear light; +/// Perceptual: Oklab L), and the interpolated linear-light value `y` comes out as opacity +/// `y^0.65`. Fitted on Photoshop (scripted Gradient Overlays and gradient fill layers, which +/// render identical pixels: 100 → 0 % and 100 → 20 → 90 % ramps with Smoothness 0 and 100 and +/// midpoints 25 and 50 %, within 0.015 of every 8-bit sample; a flat 60 % renders as 47 %). +const OPACITY_RESPONSE: f32 = 0.65; + +/// Dense linear opacity stops reproducing Photoshop's interpolation of a gradient's transparency +/// (Smoothness, opacity midpoints and the method, see [`OPACITY_RESPONSE`]). Returns the input +/// unchanged when it already interpolates linearly. Smooth keeps Classic's opacity curve: it was +/// not measured (the Photoshop build it was fitted on has no Smooth method). +pub fn bake_opacity(stops: Vec<(f32, f32)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, f32)> { + let method = if method == Method::Smooth { Method::Classic } else { method }; + let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); + let smoothness = if smoothness.is_finite() { smoothness.clamp(0.0, 1.0) } else { 0.0 }; + if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) { + return stops; + } + let into = |o: f32| -> f32 { + let o = if o.is_finite() { o.clamp(0.0, 1.0) } else { 1.0 }; + match method { + Method::Linear => lin(o), + Method::Perceptual => lin(o).cbrt(), + Method::Classic | Method::Smooth => o, + } + }; + let out = |v: f32| -> f32 { + let v = if v.is_finite() { v } else { 1.0 }; + let y = match method { + Method::Linear => v, + Method::Perceptual => v.max(0.0).powi(3), + Method::Classic | Method::Smooth => return v.clamp(0.0, 1.0), + }; + y.clamp(0.0, 1.0).powf(OPACITY_RESPONSE) + }; + let mut s = stops; + s.sort_by(|a, b| a.0.total_cmp(&b.0)); + let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, o)| (*t, [into(*o); 3])).collect(); + const N: usize = 96; + let mut baked: Vec<(f32, f32)> = (0..=N) + .map(|k| { + let t = k as f32 / N as f32; + // Smoothness and midpoints shape the curve as for Classic colours, in the method's space. + (t, out(eval(&pts, midpoints, smoothness, t, Method::Classic)[0])) + }) + .collect(); + baked.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + baked +} + #[cfg(test)] mod tests { use super::*; + fn opacity_at(stops: &[(f32, f32)], t: f32) -> f32 { + let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let (a, b) = (stops[i], stops[i + 1]); + a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) + } + + /// Photoshop Gradient Overlay samples (black, Normal, over white; a 256 px wide linear + /// gradient, pixel `x` at t = (x + 0.5) / 256): opacity stops, midpoints, Smoothness, method, + /// and (x, opacity) pairs read from the composite. + #[test] + fn opacity_matches_photoshop_samples() { + type Case = (&'static [(f32, f32)], &'static [f32], f32, Method, &'static [(f32, f32)]); + let ramp: &[(f32, f32)] = &[(0.0, 1.0), (1.0, 0.0)]; + let three: &[(f32, f32)] = &[(0.0, 1.0), (0.25, 0.2), (1.0, 0.9)]; + let cases: [Case; 9] = [ + (ramp, &[0.5], 0.0, Method::Linear, &[(64.0, 0.827), (128.0, 0.631), (192.0, 0.400)]), + (ramp, &[0.5], 0.0, Method::Perceptual, &[(64.0, 0.565), (128.0, 0.255), (192.0, 0.063)]), + (ramp, &[0.25], 1.0, Method::Classic, &[(32.0, 0.796), (64.0, 0.502), (128.0, 0.298), (192.0, 0.122)]), + (ramp, &[0.25], 1.0, Method::Linear, &[(32.0, 0.859), (64.0, 0.631), (128.0, 0.447), (192.0, 0.247)]), + (ramp, &[0.25], 1.0, Method::Perceptual, &[(32.0, 0.639), (64.0, 0.255), (128.0, 0.090), (192.0, 0.016)]), + (ramp, &[0.5], 1.0, Method::Classic, &[(64.0, 0.796), (128.0, 0.502), (192.0, 0.204)]), + (three, &[0.5, 0.5], 1.0, Method::Classic, &[(32.0, 0.557), (64.0, 0.200), (160.0, 0.502)]), + (three, &[0.5, 0.5], 1.0, Method::Perceptual, &[(32.0, 0.392), (64.0, 0.106), (160.0, 0.341)]), + (three, &[0.5, 0.5], 0.0, Method::Linear, &[(32.0, 0.647), (64.0, 0.106), (160.0, 0.557)]), + ]; + for (stops, mids, smooth, m, samples) in cases { + let baked = bake_opacity(stops.to_vec(), mids, smooth, m); + for (x, want) in samples { + let got = opacity_at(&baked, (x + 0.5) / 256.0); + assert!((got - want).abs() < 0.015, "{m:?} smooth {smooth} mids {mids:?} x {x}: {got} vs Photoshop {want}"); + } + } + // A flat 60 % renders as 47 % in Linear and Perceptual, 60 % in Classic. + for (m, want) in [(Method::Linear, 0.471), (Method::Perceptual, 0.471)] { + let baked = bake_opacity(vec![(0.0, 0.6), (1.0, 0.6)], &[0.5], 0.0, m); + assert!(baked.iter().all(|o| (o.1 - want).abs() < 0.01), "{m:?}: {baked:?}"); + } + } + + #[test] + fn plain_classic_and_smooth_opacity_stay_as_stored() { + let stops = vec![(1.0, 0.0), (0.0, 1.0)]; + assert_eq!(bake_opacity(stops.clone(), &[0.5], 0.0, Method::Classic), stops); + assert_eq!(bake_opacity(stops.clone(), &[0.5], 0.0, Method::Smooth), stops); + assert_eq!(bake_opacity(vec![(0.5, 0.3)], &[], 1.0, Method::Linear), vec![(0.5, 0.3)]); + } + + #[test] + fn opacity_bake_survives_hostile_values() { + for m in [Method::Classic, Method::Perceptual, Method::Linear, Method::Smooth] { + let baked = bake_opacity(vec![(f32::NAN, f32::INFINITY), (1.0, f32::NAN), (0.5, -3.0)], &[f32::NAN, 2.0], f32::NAN, m); + assert!(baked.iter().all(|o| (0.0..=1.0).contains(&o.1)), "{m:?}: {baked:?}"); + } + } + fn at(stops: &[(f32, Color)], t: f32) -> [f32; 3] { let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); let (a, b) = (&stops[i], &stops[i + 1]); diff --git a/crates/io/src/guides_map.rs b/crates/io/src/guides_map.rs new file mode 100644 index 0000000000..6137011e2c --- /dev/null +++ b/crates/io/src/guides_map.rs @@ -0,0 +1,157 @@ +//! Guides ⇄ PSD beyond image resource 1032. +//! +//! Photoshop writes two undocumented resources next to 1032 (Grid and Guides), layout +//! inferred from the psd-tools, ag-psd and Photoshop-authored corpora (#5965): +//! +//! * 1092, Grid and Guides version 2: `u32` version 2, `u32` horizontal and vertical grid +//! cycle (576 = 18 px in 1/32 px, as in 1032), `u32` count, then per guide 9 bytes: `i32` +//! position in 1/32 px, `u8` direction (0 vertical, 1 horizontal), one `u8` that is always 0 +//! in the corpora, and the guide's colour as `u8` R, G, B. In every corpus file its guides +//! equal 1032's, in the same order. +//! * 1097: `u32` count (1092's), then per guide a length-prefixed ASCII GUID, a `u32` 0 and a +//! second length-prefixed GUID, always all zeros. +//! +//! Import takes the guides' own colours from 1092 (a guide in Photoshop's default colour keeps +//! following the preference colour). Export keeps both resources only while 1092 still +//! describes the document's guides and colours; otherwise it leaves both out, so the file +//! carries 1032 alone, as Photoshop files before version 2 did (#5966). + +use photocraft_doc::{Document, Guides}; + +/// Grid and Guides, version 2 (positions, directions and colours). +pub const GUIDES_V2: u16 = 1092; +/// One GUID per guide, in 1092's order. +pub const GUIDE_IDS: u16 = 1097; +/// Photoshop's default guide colour (Cyan, `#4affff`), which 1092 also stores for guides +/// that use the preference colour. +pub const DEFAULT_GUIDE_RGB: [u8; 3] = [0x4a, 0xff, 0xff]; + +/// One guide of a 1092 resource. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct GuideV2 { + /// Position in 1/32 px. + pub location: i32, + pub vertical: bool, + pub rgb: [u8; 3], +} + +fn be_u32(data: &[u8], at: usize) -> Option { + let b = data.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// The guides of a 1092 resource, or `None` when it is not version 2 or is cut short. +pub fn parse_v2(data: &[u8]) -> Option> { + if be_u32(data, 0)? != 2 { + return None; + } + let count = usize::try_from(be_u32(data, 12)?).ok()?; + // Each guide takes 9 bytes: a count the data can't hold is not a 1092 we understand. + if count > data.len().saturating_sub(16) / 9 { + return None; + } + (0..count) + .map(|i| { + let at = 16 + i * 9; + let g = data.get(at..at + 9)?; + Some(GuideV2 { location: i32::from_be_bytes([g[0], g[1], g[2], g[3]]), vertical: g[4] != 1, rgb: [g[6], g[7], g[8]] }) + }) + .collect() +} + +/// The number of entries a 1097 resource declares. +pub fn ids_count(data: &[u8]) -> Option { + be_u32(data, 0) +} + +/// The document's guides as 1092 would store them, sorted. +fn expected(guides: &Guides) -> Vec { + let mut v: Vec = [(&guides.vertical, true), (&guides.horizontal, false)] + .into_iter() + .flat_map(|(list, vertical)| { + list.iter().map(move |&p| GuideV2 { location: (p * 32.0).round() as i32, vertical, rgb: guides.color(vertical, p).unwrap_or(DEFAULT_GUIDE_RGB) }) + }) + .collect(); + v.sort(); + v +} + +/// Whether an imported 1092 still describes the document's guides: the same positions, +/// directions and colours, in any order. +pub fn describes(doc: &Document, data: &[u8]) -> bool { + let Some(mut stored) = parse_v2(data) else { return false }; + stored.sort(); + stored == expected(&doc.guides) +} + +/// Whether to write back an imported 1092 or 1097: only while 1092 describes the guides +/// (and, for 1097, holds as many entries as 1092). +pub fn keep_resource(doc: &Document, id: u16, data: &[u8]) -> bool { + match id { + GUIDES_V2 => describes(doc, data), + GUIDE_IDS => { + let v2 = doc.metadata.psd_resources.iter().find(|(rid, _, _)| *rid == GUIDES_V2); + v2.is_some_and(|(_, _, d)| describes(doc, d) && parse_v2(d).map(|g| g.len()).and_then(|n| u32::try_from(n).ok()) == ids_count(data)) + } + _ => true, + } +} + +/// Gives the document's guides the colours an imported 1092 stores. Only guides that 1032 +/// also has are coloured, and Photoshop's default colour is left to the preference. +pub fn apply_colors(doc: &mut Document) { + let Some(stored) = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == GUIDES_V2).and_then(|(_, _, d)| parse_v2(d)) else { + return; + }; + for g in stored { + if g.rgb == DEFAULT_GUIDE_RGB { + continue; + } + let pos = f64::from(g.location) / 32.0; + let list = if g.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; + if list.contains(&pos) { + doc.guides.set_color(g.vertical, pos, Some(g.rgb)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v + } + + #[test] + fn parses_corpus_layout() { + // clipping-mask4.psd (psd-tools): one vertical and one horizontal guide at 16 px. + let d = v2(&[(512, 0, DEFAULT_GUIDE_RGB), (512, 1, DEFAULT_GUIDE_RGB)]); + let g = parse_v2(&d).unwrap(); + assert_eq!(g.len(), 2); + assert!(g[0].vertical && !g[1].vertical); + assert_eq!(g[1].location, 512); + } + + #[test] + fn rejects_other_versions_and_short_or_hostile_data() { + let mut d = v2(&[(512, 0, DEFAULT_GUIDE_RGB)]); + assert!(parse_v2(&d[..d.len() - 1]).is_none()); + d[3] = 1; + assert!(parse_v2(&d).is_none()); + let mut huge = v2(&[]); + huge[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse_v2(&huge).is_none()); + assert!(parse_v2(&[]).is_none()); + assert!(ids_count(&[0, 0]).is_none()); + } +} diff --git a/crates/io/src/hue_sat_filter_map.rs b/crates/io/src/hue_sat_filter_map.rs new file mode 100644 index 0000000000..d7ce04938d --- /dev/null +++ b/crates/io/src/hue_sat_filter_map.rs @@ -0,0 +1,382 @@ +//! Hue/Saturation smart filters (`Fltr` class `HStr`, filterID `HStr` as a long), as Photoshop +//! records Image › Adjustments › Hue/Saturation applied to a smart object: +//! +//! ```text +//! Fltr = HStr { presetKind = presetKindType.presetKindCustom, Clrz = bool, +//! Adjs = [ Hst2 { H , Strt, Lght } (master, first) +//! Hst2 { LclR (1 reds … 6 magentas), BgnR, BgnS, EndS, EndR, (one per edited range) +//! H , Strt, Lght } ] } +//! ``` +//! +//! Every number is a long: hue in degrees (a shift of -180..180, or 0..360 with Colorize), the +//! rest in percent; the four range sliders are hue degrees (reds: 315, 345, 15, 45). The filter +//! maps to `image.adjustments.hueSaturation` with the command's own params, so it re-renders +//! when the smart object does. Anything else (an unknown key, a duplicated or unknown range, a +//! value Photoshop's dialog can't hold) stays an opaque [`super::UNSUPPORTED_FILTER`]. + +use photocraft_doc::SmartFilter; +use photocraft_doc::adjust::HueRange; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; +use serde_json::{Map, Value as J, json}; + +use super::{blend_options, enumv, is_meta, rgbc}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "HStr"; +pub(super) const COMMAND: &str = "image.adjustments.hueSaturation"; +const FILTER_ID: i32 = super::code(b"HStr"); +/// `Nm ` (Photoshop writes the localized menu item). +const NAME: &str = "Hue/Saturation..."; +/// The `Fltr` descriptor's own name. +const FLTR_NAME: &str = "Hue/Saturation"; + +/// The colour ranges in `LclR` order (1 = reds), as the command's param keys. +const RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; +const BOUNDS: [&str; 4] = ["BgnR", "BgnS", "EndS", "EndR"]; +const SETTINGS: [(&str, &str); 3] = [("H ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")]; + +/// A whole, finite number within `lo..=hi`, as JSON. +fn whole(v: Option<&Value>, lo: f64, hi: f64) -> Option { + let v = num(v).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v))?; + Some(json!(v as i64)) +} + +/// `image.adjustments.hueSaturation` params for an `HStr` filter; `None` when any part of it is +/// not the observed layout. +pub(super) fn import_params(f: &Descriptor) -> Option { + if !f.class_id.is(CLASS) || f.items.iter().any(|(k, _)| !["presetKind", "Clrz", "Adjs"].iter().any(|n| k.is(n))) { + return None; + } + let colorize = match f.get("Clrz") { + Some(Value::Boolean(b)) => *b, + None => false, + Some(_) => return None, + }; + let Some(Value::List(entries)) = f.get("Adjs") else { return None }; + // A master and six ranges at most. + if entries.len() > 1 + RANGES.len() { + return None; + } + let mut out = Map::new(); + out.insert("colorize".into(), J::Bool(colorize)); + let mut seen_master = false; + for e in entries { + let Value::Descriptor(e) = e else { return None }; + if e.items.iter().any(|(k, _)| !k.is("LclR") && !BOUNDS.iter().any(|n| k.is(n)) && !SETTINGS.iter().any(|(n, _)| k.is(n))) { + return None; + } + let mut o = Map::new(); + match e.get("LclR") { + None => { + if std::mem::replace(&mut seen_master, true) { + return None; + } + let (hue, sat) = if colorize { ((0.0, 360.0), (0.0, 100.0)) } else { ((-180.0, 180.0), (-100.0, 100.0)) }; + o.insert("hue".into(), whole(e.get("H "), hue.0, hue.1)?); + o.insert("saturation".into(), whole(e.get("Strt"), sat.0, sat.1)?); + o.insert("lightness".into(), whole(e.get("Lght"), -100.0, 100.0)?); + out.extend(o); + } + r => { + let i = whole(r, 1.0, RANGES.len() as f64)?.as_u64()?; + let key = *RANGES.get(usize::try_from(i).ok()?.checked_sub(1)?)?; + if out.contains_key(key) { + return None; + } + o.insert("hue".into(), whole(e.get("H "), -180.0, 180.0)?); + o.insert("saturation".into(), whole(e.get("Strt"), -100.0, 100.0)?); + o.insert("lightness".into(), whole(e.get("Lght"), -100.0, 100.0)?); + let bounds: Option> = BOUNDS.iter().map(|k| whole(e.get(k), -720.0, 720.0)).collect(); + o.insert("range".into(), J::Array(bounds?)); + out.insert(key.into(), J::Object(o)); + } + } + } + Some(J::Object(out)) +} + +/// A number param of `o` (`default` when absent), finite. +fn number(o: &J, key: &str, default: f64) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or(format!("Hue/Saturation `{key}` must be a number")), + } +} + +/// A number param rounded and clamped like the dialog's field, as a long. +fn setting(o: &J, key: &str, default: f64, lo: f64, hi: f64) -> Result { + Ok(long(number(o, key, default)?, lo, hi)) +} + +fn long(v: f64, lo: f64, hi: f64) -> Value { + Value::Integer(v.round().clamp(lo, hi) as i32) +} + +/// The `Adjs` entry of colour range `i` (0 = reds), or `None` when it is neutral with its default +/// extent (Photoshop lists only the ranges that were edited). +fn range_entry(i: usize, o: &J) -> Result, String> { + let obj = o.as_object().ok_or(format!("Hue/Saturation `{}` must be an object", RANGES.get(i).unwrap_or(&"")))?; + if let Some(k) = obj.keys().find(|k| !["hue", "saturation", "lightness", "range"].contains(&k.as_str())) { + return Err(format!("Hue/Saturation range setting `{k}` has no PSD equivalent")); + } + let neutral = HueRange::neutral(i); + let bounds = match o.get("range") { + None => neutral.bounds, + Some(r) => { + let a = r.as_array().filter(|a| a.len() == 4).ok_or("a Hue/Saturation range must be four hue degrees")?; + let mut b = [0f32; 4]; + for (slot, v) in b.iter_mut().zip(a) { + *slot = v.as_f64().filter(|v| v.is_finite()).ok_or("a Hue/Saturation range must be four hue degrees")? as f32; + } + HueRange::canonical_bounds(b) + } + }; + let hue = setting(o, "hue", 0.0, -180.0, 180.0)?; + let sat = setting(o, "saturation", 0.0, -100.0, 100.0)?; + let light = setting(o, "lightness", 0.0, -100.0, 100.0)?; + let zero = Value::Integer(0); + if [&hue, &sat, &light].iter().all(|v| **v == zero) && bounds == neutral.bounds { + return Ok(None); + } + let lclr = i32::try_from(i + 1).map_err(|e| e.to_string())?; + let mut d = Descriptor::new("Hst2").with("LclR", Value::Integer(lclr)); + for (k, v) in BOUNDS.iter().zip(bounds) { + // Photoshop writes the sliders within one turn (reds: 315, 345, 15, 45). + d = d.with(k, Value::Integer(f64::from(v).round().rem_euclid(360.0) as i32)); + } + Ok(Some(Value::Descriptor(d.with("H ", hue).with("Strt", sat).with("Lght", light)))) +} + +/// The `filterFXList` item for an `image.adjustments.hueSaturation` smart filter. +pub(super) fn export_item(f: &SmartFilter) -> Result { + let p = &f.params; + let obj = match p { + J::Object(o) => o, + J::Null => &Map::new(), + _ => return Err("Hue/Saturation parameters must be an object".into()), + }; + let known = |k: &str| ["hue", "saturation", "lightness", "colorize"].contains(&k) || RANGES.contains(&k); + if let Some(k) = obj.keys().find(|k| !is_meta(k) && !known(k)) { + return Err(format!("Hue/Saturation setting `{k}` has no PSD equivalent")); + } + let colorize = match p.get("colorize") { + None => false, + Some(v) => v.as_bool().ok_or("Hue/Saturation `colorize` must be true or false")?, + }; + // As the command: Colorize hue is 0..360 and starts at 25 % saturation unless one is given. + let master = if colorize { + (long(number(p, "hue", 0.0)?.rem_euclid(360.0), 0.0, 360.0), setting(p, "saturation", 25.0, 0.0, 100.0)?) + } else { + (setting(p, "hue", 0.0, -180.0, 180.0)?, setting(p, "saturation", 0.0, -100.0, 100.0)?) + }; + let mut adjs = vec![Value::Descriptor( + Descriptor::new("Hst2").with("H ", master.0).with("Strt", master.1).with("Lght", setting(p, "lightness", 0.0, -100.0, 100.0)?), + )]; + for (i, key) in RANGES.iter().enumerate() { + if let Some(o) = p.get(*key) { + adjs.extend(range_entry(i, o)?); + } + } + let mut fltr = Descriptor::new(CLASS) + .with("presetKind", enumv("presetKindType", "presetKindCustom")) + .with("Clrz", Value::Boolean(colorize)) + .with("Adjs", Value::List(adjs)); + fltr.name = UnicodeString::new_nul(FLTR_NAME); + Ok(Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(NAME))) + .with("blendOptions", blend_options(f)) + .with("enab", Value::Boolean(f.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(FILTER_ID))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter}; + use photocraft_color::BlendMode; + use photocraft_psd::descriptor::Id; + + fn hst2(range: Option<(i32, [i32; 4])>, h: i32, s: i32, l: i32) -> Value { + let mut d = Descriptor::new("Hst2"); + if let Some((r, b)) = range { + d = d.with("LclR", Value::Integer(r)); + for (k, v) in BOUNDS.iter().zip(b) { + d = d.with(k, Value::Integer(v)); + } + } + Value::Descriptor(d.with("H ", Value::Integer(h)).with("Strt", Value::Integer(s)).with("Lght", Value::Integer(l))) + } + + fn entry(items: &[(&str, Value)]) -> Value { + Value::Descriptor(items.iter().fold(Descriptor::new("Hst2"), |d, (k, v)| d.with(k, v.clone()))) + } + + fn fltr(colorize: bool, adjs: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("presetKind", enumv("presetKindType", "presetKindCustom")) + .with("Clrz", Value::Boolean(colorize)) + .with("Adjs", Value::List(adjs)); + f.name = UnicodeString::new_nul("Farbton/Sättigung"); + f + } + + /// A `filterFXList` item exactly as Photoshop saved it (German UI: localized names, the + /// foreground colour of the moment in `FrgC`). + fn item(f: Descriptor) -> Descriptor { + let c = |r: f64, g: f64, b: f64| { + Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(r)).with("Grn ", Value::Double(g)).with("Bl ", Value::Double(b))) + }; + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Farbton/Sättigung..."))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", c(167.0, 157.0, 147.0)) + .with("BckC", c(255.0, 255.0, 255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(FILTER_ID)) + } + + /// Master 12 / -20 / 5 and Yellows -16 / 10 / -5 over 15, 45, 75, 105, Colorize off. + fn observed() -> Descriptor { + item(fltr(false, vec![hst2(None, 12, -20, 5), hst2(Some((2, [15, 45, 75, 105])), -16, 10, -5)])) + } + + fn fltr_of(d: &Descriptor) -> &Descriptor { + match d.get("Fltr") { + Some(Value::Descriptor(f)) => f, + _ => panic!("no Fltr"), + } + } + + fn filter(params: J) -> SmartFilter { + SmartFilter { command: COMMAND.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true } + } + + #[test] + fn filter_id_is_hstr() { + assert_eq!(FILTER_ID, 1213428850); + } + + #[test] + fn photoshop_items_map_to_the_command() { + let f = filter_from_item(&observed()); + assert_eq!(f.command, COMMAND); + assert_eq!( + f.params, + json!({"colorize": false, "hue": 12, "saturation": -20, "lightness": 5, + "yellows": {"hue": -16, "saturation": 10, "lightness": -5, "range": [15, 45, 75, 105]}}) + ); + let c = filter_from_item(&item(fltr(true, vec![hst2(None, 200, 40, -10)]))); + assert_eq!(c.command, COMMAND); + assert_eq!(c.params, json!({"colorize": true, "hue": 200, "saturation": 40, "lightness": -10})); + } + + #[test] + fn export_writes_photoshops_layout_and_round_trips() { + let reds = item(fltr(false, vec![hst2(None, 0, 0, 0), hst2(Some((1, [315, 345, 15, 45])), 30, 0, 0)])); + for src in [observed(), item(fltr(true, vec![hst2(None, 200, 40, -10)])), reds] { + let f = filter_from_item(&src); + let out = item_for_filter(&f).unwrap(); + let keys = |d: &Descriptor| d.items.iter().map(|(k, _)| k.clone()).collect::>(); + assert_eq!(keys(&out), keys(&src), "item key order"); + let (a, b) = (fltr_of(&out), fltr_of(&src)); + assert_eq!((&a.class_id, &a.items), (&b.class_id, &b.items), "Fltr as Photoshop writes it"); + assert_eq!(out.get("filterID"), Some(&Value::Integer(FILTER_ID))); + assert_eq!(filter_from_item(&out), f); + } + // Blending and visibility are the model's own. + let mut f = filter_from_item(&observed()); + (f.opacity, f.blend, f.visible) = (0.5, BlendMode::Screen, false); + assert_eq!(filter_from_item(&item_for_filter(&f).unwrap()), f); + } + + #[test] + fn command_params_are_written_like_the_dialog() { + let export = |p: J| filter_from_item(&item_for_filter(&filter(p)).unwrap()).params; + // Partial params: the command's defaults, numbers rounded and clamped. + assert_eq!(export(json!({"hue": 12.4})), json!({"colorize": false, "hue": 12, "saturation": 0, "lightness": 0})); + assert_eq!(export(json!({"hue": -400, "lightness": 101, "layer": 3})), json!({"colorize": false, "hue": -180, "saturation": 0, "lightness": 100})); + assert_eq!(export(J::Null), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0})); + // Colorize starts at 25 % saturation and wraps the hue into 0..360. + assert_eq!(export(json!({"colorize": true, "hue": -30})), json!({"colorize": true, "hue": 330, "saturation": 25, "lightness": 0})); + // The full set (`to_params`): neutral ranges at their default extent are left out, reds + // are written within one turn. + let mut full = json!({"hue": 0, "saturation": 0, "lightness": 0, "colorize": false}); + for (i, k) in RANGES.iter().enumerate() { + let r = HueRange::neutral(i); + full[*k] = json!({"hue": r.hue, "saturation": r.saturation, "lightness": r.lightness, "range": r.bounds}); + } + full["reds"]["saturation"] = json!(-50.0); + full["blues"]["range"] = json!([190, 220, 250, 280]); + let back = export(full); + assert_eq!(back["reds"], json!({"hue": 0, "saturation": -50, "lightness": 0, "range": [315, 345, 15, 45]})); + assert_eq!(back["blues"], json!({"hue": 0, "saturation": 0, "lightness": 0, "range": [190, 220, 250, 280]})); + assert_eq!(RANGES.iter().filter(|k| back.get(**k).is_some()).count(), 2, "{back}"); + } + + #[test] + fn unreadable_items_stay_opaque_and_are_written_back() { + let zero = Value::Integer(0); + let b4 = [0, 30, 60, 90]; + let cases = vec![ + fltr(false, vec![hst2(None, 1, 2, 3)]).with("Lab ", Value::Boolean(true)), + fltr(false, vec![hst2(None, 1, 2, 3), hst2(None, 1, 2, 3)]), + fltr(false, vec![hst2(None, 0, 0, 0), hst2(Some((2, b4)), 1, 0, 0), hst2(Some((2, b4)), 2, 0, 0)]), + fltr(false, vec![hst2(Some((0, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(Some((7, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(Some((-1, b4)), 1, 0, 0)]), + fltr(false, vec![hst2(None, 181, 0, 0)]), + fltr(false, vec![hst2(None, 0, -101, 0)]), + fltr(true, vec![hst2(None, -1, 0, 0)]), + fltr(true, vec![hst2(None, 10, -5, 0)]), + fltr(false, vec![hst2(Some((3, [0, 1, 2, 9999])), 1, 0, 0)]), + fltr(false, vec![Value::Integer(4)]), + fltr(false, vec![entry(&[("H ", zero.clone()), ("Strt", zero.clone()), ("Lght", zero.clone()), ("Clr ", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Double(f64::NAN)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Double(1.5)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("H ", Value::Boolean(true)), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, vec![entry(&[("LclR", Value::Integer(1)), ("H ", zero.clone()), ("Strt", zero.clone()), ("Lght", zero.clone())])]), + fltr(false, (0..8).map(|i| hst2(Some((i % 6 + 1, b4)), 0, 0, 0)).collect()), + Descriptor::new(CLASS).with("Clrz", Value::Integer(1)).with("Adjs", Value::List(vec![hst2(None, 1, 2, 3)])), + Descriptor::new(CLASS).with("Clrz", Value::Boolean(false)), + Descriptor::new(CLASS).with("Adjs", Value::Integer(0)), + ]; + for (n, f) in cases.into_iter().enumerate() { + let src = item(f); + let sf = filter_from_item(&src); + assert_eq!(sf.command, UNSUPPORTED_FILTER, "case {n}"); + assert_eq!(item_for_filter(&sf).unwrap().to_bytes(), src.to_bytes(), "case {n}"); + } + // No `Fltr` at all. + let mut bare = observed(); + bare.items.retain(|(k, _)| !k.is("Fltr")); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn bad_exports_report_errors() { + for p in [ + json!(3), + json!({"hue": "red"}), + json!({"colorize": "yes"}), + json!({"preset": "sepia"}), + json!({"reds": 4}), + json!({"reds": {"hue": "x"}}), + json!({"reds": {"range": [1, 2, 3]}}), + json!({"reds": {"range": [1, 2, 3, "x"]}}), + json!({"reds": {"width": 2}}), + ] { + assert!(item_for_filter(&filter(p.clone())).is_err(), "{p}"); + } + // Huge numbers clamp rather than overflow. + let out = filter_from_item(&item_for_filter(&filter(json!({"hue": f64::MAX, "colorize": true, "saturation": -1e300}))).unwrap()); + assert_eq!(out.command, COMMAND); + } +} diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs index 4316a74c10..92e951cdb8 100644 --- a/crates/io/src/kra/tests.rs +++ b/crates/io/src/kra/tests.rs @@ -28,7 +28,8 @@ fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { planar[k * n + p] = px[p * pixel_size + k]; } } - let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + // Krita splits only LZF tiles into byte planes; raw tiles stay interleaved. + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [&[0][..], px].concat() }; out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); out.extend(body); } @@ -160,6 +161,22 @@ fn a_nonzero_default_pixel_fills_untouched_areas() { assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); } +#[test] +fn raw_tiles_hold_interleaved_pixels() { + // Krita writes a tile uncompressed (flag 0) when LZF can't shrink it, and then the pixels + // stay interleaved: only LZF tiles are split into byte planes. + let mut body = vec![0u8]; + body.extend((0..T * T).flat_map(|_| [10u8, 20, 30, 255])); + let mut file = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,{}\n", body.len()).into_bytes(); + file.extend(body); + let doc = maindoc("RGBA", r#""#); + let r = crate::import("a.kra", &archive(&doc, &[("img/layers/l", file)])).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + for (x, y) in [(0, 0), (1, 0), (3, 3)] { + assert_eq!(s.to_interleaved(Rect::from_xywh(x, y, 1, 1)), [30, 20, 10, 255], "pixel {x},{y}"); + } +} + fn merged_png() -> Vec { let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index 8af6638aa8..67fd02fca5 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,19 +231,22 @@ impl<'a> TileFile<'a> { for &(x, y, data) in &self.tiles { ctl.check().map_err(|_| IoError::Cancelled)?; let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; - let planar: std::borrow::Cow<'_, [u8]> = match flag { - 1 => lzf_decompress(payload, expected)?.into(), - 0 if payload.len() == expected => payload.into(), - 0 => return Err(invalid("raw tile has the wrong size")), - f => return Err(invalid(format!("unknown tile compression flag {f}"))), - }; - // Byte planes: byte k of pixel i is at k * n + i. - for (k, plane) in planar.chunks_exact(n).enumerate() { - for (i, b) in plane.iter().enumerate() { - if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { - *slot = *b; + match flag { + 1 => { + // LZF tiles hold byte planes: byte k of pixel i is at k * n + i. + let planar = lzf_decompress(payload, expected)?; + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } } } + // Raw tiles (written when LZF doesn't shrink them) are already interleaved. + 0 if payload.len() == expected => interleaved.copy_from_slice(payload), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), } space.convert(&interleaved, fmt, &mut out); let (x0, y0) = diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 24b8b3f948..a0fc3e87e2 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -20,6 +20,8 @@ //! opacity and visibility; see `ora`. //! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency //! masks (import only); see `kra`. +//! * GIMP XCF (`.xcf`, `.xcf.gz`) documents open with their layers, groups, masks, blend modes, +//! channels and selection, read only; see `xcf`. //! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively //! with no source save path, what isn't imported listed in the warnings; a file //! whose native data can't be read opens as its embedded preview; see `affinity`. @@ -40,19 +42,25 @@ pub mod blocks; pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; +pub mod doc_path; pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub mod guides_map; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; +mod duotone_map; mod kra; pub mod linked; mod multichannel_map; mod ora; +mod palette_map; pub use ora::losses as ora_losses; pub mod pattern_map; +mod pcraft_cache; pub mod pdf; mod pdn; +pub use pcraft_cache::PcraftExportCache; pub mod pixels; mod psd_export; mod psd_import; @@ -65,16 +73,17 @@ mod text_import; pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +mod xcf; -pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; -use photocraft_doc::Document; +pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, - tga_default_bits, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames, + tga_alpha_channel_to_transparency, tga_default_bits, }; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; @@ -91,6 +100,9 @@ pub enum IoError { /// OpenRaster read failure. #[error("OpenRaster: {0}")] Ora(String), + /// A GIMP XCF file that could not be read. + #[error("XCF: {0}")] + Xcf(String), /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), @@ -139,6 +151,34 @@ pub(crate) fn unequal_resolution_warning(x: f64, y: f64) -> Option { .then(|| format!("the vertical resolution ({y:.2} ppi) differs from the horizontal ({x:.2} ppi); the document keeps {x:.2} ppi for both")) } +/// Warnings about a loaded document's layers that render without their stored effect, for +/// sources that carry no import warnings of their own (a crafted `.pcraft`): a Color Lookup +/// whose table can't be applied silently renders as the identity instead (#1763). +pub fn open_warnings(doc: &Document) -> Vec { + let mut out = Vec::new(); + // Iterative, so no document's nesting can make this recurse. + let mut stack: Vec<&Layer> = doc.layers.iter().rev().collect(); + while let Some(l) = stack.pop() { + if let LayerContent::Adjustment(Adjustment::ColorLookup { name: _, size, lut, .. }) = &l.content { + match lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)) { + Some((table, len)) if table.len() >= len => {} + Some((table, len)) => out.push(format!( + "layer \"{}\": its Color Lookup table has {} values, but a {size}³ lookup needs {len}; the layer renders without the lookup", + l.name, + table.len() + )), + None => { + out.push(format!("layer \"{}\": its Color Lookup has no usable table for a {size}³ lookup; the layer renders without the lookup", l.name)) + } + } + } + if let Some(children) = l.children() { + stack.extend(children.iter().rev()); + } + } + out +} + /// Result of [`export`]. #[derive(Debug, Clone)] pub struct ExportResult { @@ -187,6 +227,11 @@ pub struct ExportOptions { /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as /// Photoshop's Always. pub psd_maximize_compatibility: bool, + /// The path of the document's own file, which linked smart objects that PSD/PSB and layered + /// TIFF embed are resolved against ([`doc_path::resolve`]). + pub document_path: Option, + /// Reuse one cache per open document for native saves; other formats ignore it. + pub pcraft_cache: Option, } impl Default for ExportOptions { @@ -199,6 +244,8 @@ impl Default for ExportOptions { tga_bits: None, image_previews: true, psd_maximize_compatibility: true, + document_path: None, + pcraft_cache: None, } } } @@ -231,11 +278,21 @@ impl TgaBits { } } +/// Photoshop document extensions File › Open routes to the PSD reader. +const PSD_EXTS: &[&str] = &["psd", "psb", "psdt"]; + /// `true` if `bytes` start with the PSD/PSB signature. pub fn is_psd(bytes: &[u8]) -> bool { bytes.starts_with(b"8BPS") } +/// PSD/PSB/PSDT by magic, or by a declared Photoshop extension when no other raster +/// signature is present. A PNG/JPEG/TIFF downloaded as `.psd` still opens from its bytes +/// (#5258 review). +fn is_psd_input(name: &str, bytes: &[u8]) -> bool { + is_psd(bytes) || (PSD_EXTS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) +} + /// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything /// else is decoded with `photocraft-codecs`. pub fn import(name: &str, bytes: &[u8]) -> Result { @@ -261,34 +318,66 @@ pub fn import_with_svg_group_depth_and_interrupt( max_group_depth: usize, ctl: &photocraft_raster::Interrupt, ) -> Result { + import_with_options_and_interrupt(name, bytes, &ImportOptions { max_group_depth, ..Default::default() }, ctl) +} + +/// The user's settings that shape an import. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImportOptions { + /// SVG groups nested deeper than this are rasterised. + pub max_group_depth: usize, + /// The demosaic method camera raws are developed with. + pub demosaic: raw::Demosaic, +} + +impl Default for ImportOptions { + fn default() -> Self { + ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default() } + } +} + +/// [`import`] with the user's import settings. +pub fn import_with_options(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result { + import_with_options_and_interrupt(name, bytes, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`import_with`] with the user's import settings. +pub fn import_with_options_and_interrupt(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; - let r = import_stages(name, bytes, ctl, max_group_depth)?; + let r = import_stages(name, bytes, ctl, opts)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) } -fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { +fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> 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, + )?; + // 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_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; + document.name = name.to_string(); + text_import::prepare(&mut document); + Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) +} + +fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }); } + // 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) { - // 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, - )?; - // 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_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; - document.name = name.to_string(); - text_import::prepare(&mut document); - return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); + return import_psd(name, bytes, ctl); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -301,14 +390,27 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { return kra::import(name, bytes, ctl); } + // GIMP files are gzip or raw; as above, a recognized raster signature still wins over a + // misleading .xcf suffix. + if xcf::is_xcf(bytes) || (has_extension(name, "xcf") && photocraft_codecs::detect(bytes).is_none()) { + return xcf::import(name, bytes, ctl); + } + if xcf::is_gzipped_xcf(name, bytes) { + return xcf::import(name, &xcf::gunzip(bytes)?, ctl); + } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { return pdn::import(name, bytes, ctl); } if raw::is_raw(bytes) { - return raw::import_raw(name, bytes); + return raw::import_raw_demosaic(name, bytes, opts.demosaic); } if is_svg_input(name, bytes) { - return svg::import_svg_with_group_depth(name, bytes, max_group_depth); + return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); + } + // 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); } flat::import_flat(name, bytes) } @@ -393,6 +495,11 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if kra::EXTENSIONS.contains(&ext.as_str()) { return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into())); } + if ext == "xcf" { + return Err(IoError::Unsupported( + "XCF is GIMP's own format and PhotoCraft only opens it; save as .pcraft to keep the layers, or export a flat image".into(), + )); + } if ext == "ora" { return ora::export(doc); } @@ -408,10 +515,19 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); let previews = photocraft_format::SaveOptions { thumbnail, composite }; - return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); + let bytes = match &opts.pcraft_cache { + Some(cache) => cache.encode(doc.as_ref(), &previews)?, + None => photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, + }; + return Ok(ExportResult { bytes, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; + let o = PsdExportOptions { + force_psb: opts.force_psb || ext == "psb", + maximize_compatibility: opts.psd_maximize_compatibility, + document_path: opts.document_path.clone(), + ..Default::default() + }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; @@ -525,7 +641,7 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { #[cfg(test)] mod tests { - use super::is_svg_input; + use super::{import, is_psd_input, is_svg_input}; #[test] fn svg_extension_does_not_override_another_format_signature() { @@ -535,4 +651,37 @@ mod tests { assert!(is_svg_input("drawing.svgz", &[0x1f, 0x8b, 0, 0])); assert!(!is_svg_input("drawing.svgz", webp_header)); } + + #[test] + fn photoshop_extension_reaches_the_psd_loader() { + // A declared .psd/.PSB/.psdt must not fall through to codecs (#5258). + for name in ["photo.psd", "dir/Photo.PSD", r"C:\w\a.PSB", "card.psdt"] { + assert!(is_psd_input(name, b"not a signature"), "{name}"); + } + assert!(!is_psd_input("photo.png", b"not a signature")); + assert!(is_psd_input("renamed.png", b"8BPS")); + let err = import("photo.psd", b"not a photoshop file").expect_err("named .psd is a PSD parse, not a codec miss"); + let msg = err.to_string(); + assert!(msg.starts_with("PSD:"), "{msg}"); + assert!(!msg.contains("unrecognized image format"), "{msg}"); + // A renamed Affinity file keeps the Affinity loader (magic before the .psd suffix). + assert!(!super::is_psd(b"\0\xffKA")); + } + + /// A common mislabeled download: PNG/JPEG bytes named `.psd` open from the signature, + /// as on main and in Photoshop. + #[test] + fn raster_bytes_named_psd_still_open() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; + let n = 8 * 8 * 3; + let img = Image::from_u8(8, 8, ChannelLayout::Rgb, vec![180; n]).unwrap(); + let png = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + for (name, bytes) in [("download.psd", png.as_slice()), ("scan.PSB", jpeg.as_slice())] { + assert!(!is_psd_input(name, bytes), "{name}"); + let d = import(name, bytes).unwrap_or_else(|e| panic!("{name}: {e}")).document; + assert_eq!(d.bounds().width(), 8, "{name}"); + assert_eq!(d.bounds().height(), 8, "{name}"); + } + } } diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index db31e743ef..e9437deb6c 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -3,8 +3,9 @@ //! A Multichannel document has no layers: its image is a set of ink (spot) channels, stored in //! [`Document::channels`] with ink density as the value (1 = solid ink, like every spot channel). //! A PSD stores the same planes the way Photoshop shows them in the Channels panel: 0 (black) = -//! solid ink, max = paper. Channel names come from resources 1006 / 1045 and ink colours from -//! DisplayInfo (1077), exactly as for spot channels in the other modes. +//! solid ink, max = paper. Alpha-type channels (DisplayInfo kind 0 / 1) are stored as-is, like the +//! alpha channels of every other mode (max = selected). Channel names come from resources 1006 / +//! 1045 and ink colours from DisplayInfo (1077), exactly as for spot channels in the other modes. use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; @@ -61,6 +62,14 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels); } + // Alpha-type planes are stored as-is, not as ink: undo the inversion for them. + for ch in doc.channels.iter_mut().filter(|c| c.spot.is_none()) { + let vals: Vec = ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()).map(|v| 1.0 - v[0]).collect(); + let mut s = Surface::new(fmt); + s.write_region(canvas, &vals); + s.prune(); + ch.surface = s; + } } /// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane). @@ -90,8 +99,10 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< } } for ch in &channels { + // Inks are stored as tone (0 = solid ink), alpha-type channels as-is. + let ink = ch.spot.is_some(); for v in ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()) { - encode_be(1.0 - v[0], sample, &mut planes); + encode_be(if ink { 1.0 - v[0] } else { v[0] }, sample, &mut planes); } } let big = doc.size.width > 30_000 || doc.size.height > 30_000; @@ -119,7 +130,27 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false))); } - resources.extend(crate::psd_export::path_resources(doc, &mut warnings)); + // 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); + (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. +pub(crate) fn layerless_file( + doc: &Document, + header: Header, + image_data: ImageData, + color_mode_data: Vec, + mut resources: Vec, + keep: impl Fn(u16, &[u8]) -> bool, + warnings: &mut Vec, +) -> PsdFile { + resources.extend(crate::psd_export::path_resources(doc, warnings)); // The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it. let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0); if let Some(x) = &doc.metadata.xmp { @@ -132,11 +163,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< // ones that only describe layers and any already written above. let written: Vec = resources.iter().map(|r| r.id).collect(); for (id, name, data) in &doc.metadata.psd_resources { - if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) { - continue; - } - // Alpha identifiers: one u32 per channel, only while they still match the channels. - if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) { + if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) || !keep(*id, data) { continue; } let mut r = ImageResource::new(*id, data.to_vec()); @@ -152,10 +179,9 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< 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(sig, key, &data)) + .map(|(sig, key, data)| crate::psd_export::global_block(crate::psd_export::block_signature(sig, key, header.version), key, &data)) .collect(); - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); - let file = PsdFile { + PsdFile { header, color_mode_data, resources, @@ -165,6 +191,5 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< global_blocks, layer_mask_trailing: Vec::new(), image_data, - }; - (file, warnings) + } } diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index f365154333..6d1bcfd87f 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -566,6 +566,15 @@ impl Writer<'_> { if layer.locks.all || layer.locks.pixels { attrs.push_str(" edit-locked=\"true\""); } + // OpenRaster's one edit lock reopens as Lock All; it has no position lock (#7018). + if layer.locks.pixels && !layer.locks.all { + self.warnings.layer(&layer.name, "Lock Image Pixels was saved as OpenRaster's edit lock; it reopens as Lock All"); + } + // The Background's position lock is part of being the Background, not one the user set: + // every new document has it, and OpenRaster has no Background to keep it on. + if layer.locks.position && !layer.locks.all && !self.doc.is_background(layer.id) { + self.warnings.layer(&layer.name, "Lock Position is not saved to OpenRaster"); + } if layer.locks.transparency { attrs.push_str(" alpha-preserve=\"true\""); } diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 4f0b1c4c47..c7c253ef29 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -494,6 +494,9 @@ fn lossy_documents() -> Vec<(&'static str, Document)> { }), ("clipped", with(&|d| d.layers[1].clipped = true)), ("fill opacity", with(&|d| d.layers[0].fill_opacity = 0.5)), + ("pixel lock", with(&|d| d.layers[0].locks.pixels = true)), + // Not the bottom layer: with its transparency lock, `Base` would become the Background. + ("position lock", with(&|d| d.layers[1].locks.position = true)), ("float", doc(SampleType::F32)), ("profile", with(&|d| d.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()))), ("metadata", with(&|d| d.metadata.xmp = Some("".into()))), @@ -520,6 +523,15 @@ fn plain_documents_lose_nothing() { d.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); assert_eq!(losses(&d), Vec::::new(), "{depth:?} tagged sRGB"); assert_eq!(export(&d).unwrap().warnings, Vec::::new()); + // OpenRaster's edit-locked and alpha-preserve hold Lock All and Lock Transparent Pixels. + d.layers[0].locks.all = true; + d.layers[1].locks.transparency = true; + assert_eq!(losses(&d), Vec::::new(), "{depth:?} locked"); + // A Background (every new document's bottom layer) loses nothing for its position lock. + d.layers[0].locks.all = false; + d.layers[0].locks.position = true; + assert!(d.background().is_some()); + assert_eq!(losses(&d), Vec::::new(), "{depth:?} Background"); } let mut cmyk = doc(SampleType::U8); cmyk.mode = ColorMode::Cmyk; diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs new file mode 100644 index 0000000000..45b8168f1c --- /dev/null +++ b/crates/io/src/palette_map.rs @@ -0,0 +1,289 @@ +//! Indexed Color and Bitmap documents -> PSD colour modes 2 and 0. +//! +//! Neither mode has layers in Photoshop, so the file holds only the merged image (the document +//! flattened): for Indexed Color one plane of palette indices, with the Color Table as Color Mode +//! Data (768 bytes: 256 reds, 256 greens, 256 blues), its length in resource 1046 and the +//! transparent entry in resource 1047; for Bitmap one plane of 1 bit per pixel, 1 = black. + +use photocraft_color::ColorMode; +use photocraft_doc::{ColorTable, Document}; +use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids}; +use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; + +/// Indexed Color Table Count: the number of colours in the table (u16). +pub(crate) const COLOR_TABLE_COUNT: u16 = 1046; +/// Transparency Index: the colour table entry shown as transparent (u16). +pub(crate) const TRANSPARENCY_INDEX: u16 = 1047; +/// Channel resources (names, display info, alpha identifiers): these modes have no extra channels. +const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPLAY_INFO]; + +/// The PSD for an Indexed Color document with a colour table of at most 256 entries, one of them +/// 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)> { + if !doc.channels.is_empty() || doc.quick_mask.is_some() { + return None; + } + let table = doc.color_table.as_ref().filter(|t| t.colors.len() <= 256 && (0..t.colors.len()).any(|i| Some(i) != t.transparent.map(usize::from))); + let (mode, plane, color_mode_data) = match doc.mode { + ColorMode::Indexed => { + let table = table?; + (PsdMode::Indexed, indices(doc, table), palette(table)) + } + ColorMode::Bitmap => (PsdMode::Bitmap, bits(doc), Vec::new()), + _ => return None, + }; + let mut warnings = Vec::new(); + 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 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 }); + 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()), + ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(1032, crate::psd_export::guides_resource(doc)), + ]; + if let (PsdMode::Indexed, Some(t)) = (mode, table) { + resources.push(ImageResource::new(COLOR_TABLE_COUNT, (t.colors.len() as u16).to_be_bytes().to_vec())); + if let Some(i) = t.transparent { + resources.push(ImageResource::new(TRANSPARENCY_INDEX, u16::from(i).to_be_bytes().to_vec())); + } + // Photoshop keeps the RGB profile of an Indexed document (Bitmap has none). A profile of + // another colour space (kept from a Grayscale or CMYK source) does not describe the table. + if let Some(icc) = doc.icc_profile.as_ref().filter(|icc| icc.get(16..20) == Some(b"RGB ".as_slice())) { + resources.push(ImageResource::new(ids::ICC_PROFILE, icc.to_vec())); + } + } + // 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); + Some((file, warnings)) +} + +/// Whether the merged image holds everything the layers do: no layer, or a single visible pixel +/// layer at full opacity, Normal blend, unclipped, without masks or layer effects. Anything else +/// (more layers, a type, shape or smart object layer, a mask, a hidden layer) is lost when the +/// document is written flattened. +fn flattens_losslessly(doc: &Document) -> bool { + match doc.layers.as_slice() { + [] => true, + [l] => { + l.visible + && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) + && l.mask.is_none() + && l.vector_mask.is_none() + && !l.clipped + && l.opacity >= 1.0 + && l.fill_opacity >= 1.0 + && l.blend == photocraft_color::BlendMode::Normal + && !(l.effects.enabled && l.effects.items.iter().any(photocraft_doc::Effect::enabled)) + } + _ => false, + } +} + +/// The Color Table as Color Mode Data: 256 reds, 256 greens, 256 blues (unused entries black). +fn palette(t: &ColorTable) -> Vec { + let mut m = vec![0u8; 768]; + for (i, c) in t.colors.iter().take(256).enumerate() { + m[i] = c[0]; + m[256 + i] = c[1]; + m[512 + i] = c[2]; + } + m +} + +/// The flattened document as palette indices: the transparent entry where the document is less +/// than half covered, else the nearest other entry (the transparent one may duplicate an opaque +/// colour, as in many PNG-8 files). Without a transparent entry the document is flattened over +/// white, as Photoshop does. +fn indices(doc: &Document, t: &ColorTable) -> Vec { + let (opaque_indices, colors): (Vec, Vec<_>) = + t.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != t.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip(); + let opaque = ColorTable { colors, transparent: None }; + let mut idx = Vec::with_capacity(doc.size.area() as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + idx.extend(band.px.iter().map(|p| match t.transparent { + Some(i) if p[3] < 0.5 => i, + Some(_) => opaque_indices.get(opaque.nearest([p[0], p[1], p[2]])).copied().unwrap_or(0), + None => { + let over_white = |c: f32| c * p[3] + (1.0 - p[3]); + t.nearest([over_white(p[0]), over_white(p[1]), over_white(p[2])]) as u8 + } + })); + Ok(()) + }); + idx +} + +/// The flattened document over white as 1-bit rows (padded to a byte), 1 = black: pixels darker +/// than middle grey. +fn bits(doc: &Document) -> Vec { + let w = doc.size.width as usize; + let row = w.div_ceil(8); + let mut out = Vec::with_capacity(row * doc.size.height as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + for line in band.px.chunks(w.max(1)) { + let mut bytes = vec![0u8; row]; + for (x, p) in line.iter().enumerate() { + let grey = (p[0] + p[1] + p[2]) / 3.0 * p[3] + (1.0 - p[3]); + if grey < 0.5 { + bytes[x / 8] |= 0x80 >> (x % 8); + } + } + out.extend(bytes); + } + Ok(()) + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, SampleType}; + use photocraft_doc::Size; + + fn document(width: u32, height: u32, depth: SampleType, mode: ColorMode, pixels: &[f32]) -> Document { + let mut doc = Document::with_background("palette test", Size::new(width, height), ColorMode::Rgb, depth, Color::rgba(0.0, 0.0, 0.0, 1.0)); + let bounds = doc.bounds(); + doc.layers[0].surface_mut().unwrap().write_region(bounds, pixels); + doc.mode = mode; + doc + } + + fn round_trip(doc: &Document) -> (PsdFile, Document) { + let (file, _) = document_to_psd(doc, false).unwrap(); + let bytes = file.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let back = crate::import("test.psd", &bytes).unwrap().document; + (file, back) + } + + #[test] + fn full_palette_endpoints_round_trip_with_either_transparent_endpoint() { + let colors: Vec<_> = (0..=255).map(|v| [v, 255 - v, v / 2]).collect(); + for transparent in [0, 255] { + let opaque = 255 - transparent; + let rgb = colors[usize::from(opaque)].map(|v| f32::from(v) / 255.0); + let mut doc = document(2, 1, SampleType::U8, ColorMode::Indexed, &[rgb[0], rgb[1], rgb[2], 1.0, 0.0, 0.0, 0.0, 0.0]); + doc.color_table = Some(ColorTable { colors: colors.clone(), transparent: Some(transparent) }); + let (file, back) = round_trip(&doc); + assert_eq!((file.header.color_mode, file.header.depth, file.header.channels), (PsdMode::Indexed, 8, 1)); + assert_eq!(file.decode_merged().unwrap(), [opaque, transparent]); + assert_eq!(file.resource(COLOR_TABLE_COUNT).unwrap().data, [1, 0]); + assert_eq!(file.color_mode_data.len(), 768); + for (i, c) in colors.iter().enumerate() { + assert_eq!([file.color_mode_data[i], file.color_mode_data[256 + i], file.color_mode_data[512 + i]], *c); + } + assert_eq!(back.color_table, doc.color_table); + let surface = back.layers[0].surface().unwrap(); + assert_eq!(surface.pixel(0, 0), vec![rgb[0], rgb[1], rgb[2], 1.0]); + assert_eq!(surface.pixel(1, 0)[3], 0.0); + } + } + + #[test] + fn indexed_alpha_threshold_uses_stored_samples_at_all_depths() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(3, 1, depth, ColorMode::Indexed, &[0.0, 0.0, 0.0, 0.49, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.51]); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: Some(1) }); + let (file, back) = round_trip(&doc); + assert_eq!(file.decode_merged().unwrap(), [1, 0, 0], "{depth:?}"); + let surface = back.layers[0].surface().unwrap(); + assert_eq!((0..3).map(|x| surface.pixel(x, 0)[3]).collect::>(), [0.0, 1.0, 1.0]); + } + } + + #[test] + fn small_palette_zero_pads_unused_entries_and_rejects_invalid_lengths() { + let mut doc = document(1, 1, SampleType::U8, ColorMode::Indexed, &[1.0, 0.0, 0.0, 1.0]); + doc.color_table = Some(ColorTable { colors: vec![[255, 0, 0]], transparent: None }); + let (file, back) = round_trip(&doc); + assert_eq!(file.color_mode_data[0], 255); + assert!(file.color_mode_data[1..].iter().all(|&v| v == 0)); + 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}"); + } + doc.color_table = None; + assert!(document_to_psd(&doc, false).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(); + 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); + file.resources.push(ImageResource::new(COLOR_TABLE_COUNT, count)); + file.resources.push(ImageResource::new(TRANSPARENCY_INDEX, vec![255, 255])); + let bytes = file.to_bytes().unwrap(); + let back = crate::import("malformed.psd", &bytes).unwrap().document; + let table = back.color_table.unwrap(); + assert_eq!(table.colors.len(), expected); + assert_eq!(table.transparent, None); + assert_eq!(back.layers[0].surface().unwrap().pixel(0, 0)[3], 1.0); + } + } + + #[test] + fn bitmap_rows_are_msb_first_and_independently_byte_padded() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for width in [1u32, 7, 8, 9, 15, 17] { + // Opposite rows expose padding carried into the next row or reversed bit order. + let pixels: Vec<_> = (0..2) + .flat_map(|y| { + (0..width).flat_map(move |x| { + let v = if (x + y) % 2 == 0 { 0.0 } else { 1.0 }; + [v, v, v, 1.0] + }) + }) + .collect(); + let doc = document(width, 2, depth, ColorMode::Bitmap, &pixels); + let (file, back) = round_trip(&doc); + assert_eq!((file.header.color_mode, file.header.depth, file.header.channels), (PsdMode::Bitmap, 1, 1)); + let mut expected = Vec::new(); + for y in 0..2 { + for byte in 0..width.div_ceil(8) { + let mut packed = 0; + for bit in 0..8 { + let x = byte * 8 + bit; + if x < width && (x + y) % 2 == 0 { + packed |= 0x80 >> bit; + } + } + expected.push(packed); + } + } + assert_eq!(file.decode_merged().unwrap(), expected, "{depth:?}, width={width}"); + assert!(file.color_mode_data.is_empty()); + assert_eq!(back.mode, ColorMode::Bitmap); + let gray_alpha: Vec<_> = pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[3]]).collect(); + assert_eq!(back.layers[0].surface().unwrap().read_region(back.bounds()), gray_alpha); + } + } + } + + #[test] + fn bitmap_alpha_is_flattened_over_white_before_thresholding() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(3, 1, depth, ColorMode::Bitmap, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.49, 0.0, 0.0, 0.0, 0.51]); + let (file, back) = round_trip(&doc); + assert_eq!(file.decode_merged().unwrap(), [0x20], "{depth:?}"); + assert_eq!(back.layers[0].surface().unwrap().read_region(back.bounds()), [1.0, 1.0, 1.0, 1.0, 0.0, 1.0]); + } + } +} diff --git a/crates/io/src/pcraft_cache.rs b/crates/io/src/pcraft_cache.rs new file mode 100644 index 0000000000..690169c605 --- /dev/null +++ b/crates/io/src/pcraft_cache.rs @@ -0,0 +1,74 @@ +//! Per-document native export cache; file writes and save identity stay with the caller. + +use std::sync::{Arc, Mutex}; + +use photocraft_doc::Document; +use photocraft_format::{PcraftWriter, SaveOptions}; + +/// Shared native export cache. Clones reuse tile hashes and up to 64 MiB of compressed payloads; +/// dropping the last clone releases it, including when a document closes after a save worker. +#[derive(Clone)] +pub struct PcraftExportCache(Arc>); + +impl Default for PcraftExportCache { + fn default() -> Self { + let mut writer = PcraftWriter::new(); + writer.set_compressed_cache_budget(64 << 20); + Self(Arc::new(Mutex::new(writer))) + } +} + +impl std::fmt::Debug for PcraftExportCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PcraftExportCache") + } +} + +impl PartialEq for PcraftExportCache { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl PcraftExportCache { + pub(crate) fn encode(&self, doc: &Document, opts: &SaveOptions) -> photocraft_format::Result> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).save_zip(doc, opts).map(|(bytes, _)| bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ExportOptions, export}; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + #[test] + fn cached_exports_reuse_tiles_and_preserve_edits_and_save_as() { + for depth in SampleType::ALL { + let mut doc = Document::new("first.pcraft", Size::new(2, 1), ColorMode::Rgb, depth); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + doc.layers.push(Layer::new("photo", LayerContent::Raster(surface))); + let cache = PcraftExportCache::default(); + let options = ExportOptions { pcraft_cache: Some(cache.clone()), ..Default::default() }; + let first = export(&doc, "first.pcraft", &options).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&first.bytes).unwrap(), doc); + let (_, stats) = cache.0.lock().unwrap().save_zip(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(stats.tiles_reused, stats.tiles_total); + assert_eq!(stats.tiles_written, 0); + doc.layers[0].surface_mut().unwrap().try_write_region(photocraft_geom::Rect::new(0, 0, 1, 1), &[0.8, 0.3, 0.1, 1.0]).unwrap(); + let next = export(&doc, "second.pcraft", &options).unwrap(); + let mut expected = doc.clone(); + expected.name = "second.pcraft".into(); + assert_eq!(photocraft_format::load_from_bytes(&next.bytes).unwrap(), expected); + assert_eq!(next.bytes, export(&doc, "second.pcraft", &ExportOptions::default()).unwrap().bytes); + let weak = Arc::downgrade(&cache.0); + drop(options); + drop(cache); + assert!(weak.upgrade().is_none()); + } + } +} diff --git a/crates/io/src/pdn.rs b/crates/io/src/pdn.rs index 08e294b7bc..1e2d99f7af 100644 --- a/crates/io/src/pdn.rs +++ b/crates/io/src/pdn.rs @@ -154,6 +154,12 @@ pub(super) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result) -> Result 255 { return Err(invalid("opacity exceeds 255")); } - let mut l = Layer::raster(graph.string(graph.field(props, "name")?)?, PixelFormat::RGBA8); + let mut l = Layer::raster( + { + let name = graph.string(graph.field(props, "name")?)?; + if name.len() > MAX_NAME_BYTES { + return Err(invalid("layer name is too long")); + } + name_bytes = name_bytes.saturating_add(name.len()); + if name_bytes > MAX_TOTAL_NAME_BYTES { + return Err(invalid("layer names exceed the total metadata budget")); + } + name.to_string() + }, + PixelFormat::RGBA8, + ); l.visible = graph.boolean(graph.field(props, "visible")?)?; l.opacity = opacity as f32 / 255.0; l.blend = blend(&graph, layer, props)?; diff --git a/crates/io/src/perspective_warp_map.rs b/crates/io/src/perspective_warp_map.rs new file mode 100644 index 0000000000..654de23b32 --- /dev/null +++ b/crates/io/src/perspective_warp_map.rs @@ -0,0 +1,455 @@ +//! Perspective Warp smart filters (`Fltr` class `perspectiveWarpTransform`). +//! +//! Layout as Photoshop writes it (checked by applying Edit › Perspective Warp to a smart object +//! through the Action Manager and saving the PSD): +//! +//! ```text +//! filterFX: Nm, blendOptions, enab, hasoptions (true), FrgC, BckC, filterID (1023 observed; +//! other versions write other ids), Fltr = perspectiveWarpTransform { +//! null = reference to the target layer, +//! referenceRect = Rctn { Top, Left, Btom, Rght } (UntF #Pxl, the layer bounds), +//! vertices = [ Pnt { Hrzn, Vrtc } (UntF #Pxl, document pixels) ], (Layout mode) +//! warpedVertices = [ Pnt { Hrzn, Vrtc } ] (same count), (Warp mode) +//! quads = [ perspectiveWarpQuad { indices = [4 × long] } ], +//! PuX0..PuX3, PuY0..PuY3 (doubles: the placement corners when the filter was applied) } +//! ``` +//! +//! Each quad is one plane of `layer.smartObjects.perspectiveWarp`: `src` = the quad's +//! `vertices`, `dst` = its `warpedVertices`, in the stored corner order. Quads that share a vertex +//! share the corner, which the engine links. The item is kept as a template ([`TEMPLATE_KEY`]) so +//! the keys PhotoCraft does not model go back unchanged, with only the vertices and quads +//! rewritten from the edited planes. Anything inconsistent (an index out of range, mismatched +//! vertex counts, a non-finite or degenerate quad) stays an opaque [`super::UNSUPPORTED_FILTER`]. + +use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, rgbc, set, to_hex, unit}; + +pub(super) const CLASS: &str = "perspectiveWarpTransform"; +/// The command this filter maps to (`photocraft_engine::distort_cmds::PERSPECTIVE_SMART`). +pub(super) const COMMAND: &str = "layer.smartObjects.perspectiveWarp"; +/// Edit › Perspective Warp on a smart object stores its own id; it renders the same. +const EDIT_COMMAND: &str = "edit.perspectiveWarp"; +/// The `filterID` Photoshop wrote for a new Perspective Warp smart filter. +const FILTER_ID: i32 = 1023; +const NAME: &str = "Perspective Warp..."; +pub(super) const TEMPLATE_KEY: &str = "__perspectiveWarpPsd"; +/// Far more planes than anyone lays out by hand; bounds the corner linking (quadratic). +const MAX_QUADS: usize = 256; +const MAX_VERTICES: usize = 4 * MAX_QUADS; +const MAX_TEMPLATE_BYTES: usize = 1 << 20; + +/// Whether `command` is a Perspective Warp smart filter this module writes. +pub(super) fn handles(command: &str) -> bool { + command == COMMAND || command == EDIT_COMMAND +} + +fn point(v: &Value) -> Option<[f64; 2]> { + let Value::Descriptor(p) = v else { return None }; + let coord = |k: &str| match p.get(k)? { + Value::Double(d) => Some(*d), + Value::UnitFloat { unit: [b'#', b'P', b'x', b'l'], value } => Some(*value), + _ => None, + }; + Some([coord("Hrzn")?, coord("Vrtc")?]).filter(|q| q.iter().all(|v| v.is_finite())) +} + +fn points(f: &Descriptor, key: &str) -> Option> { + let Some(Value::List(list)) = f.get(key) else { return None }; + if list.is_empty() || list.len() > MAX_VERTICES { + return None; + } + list.iter().map(point).collect() +} + +fn quads(f: &Descriptor) -> Option> { + let Some(Value::List(list)) = f.get("quads") else { return None }; + if list.is_empty() || list.len() > MAX_QUADS { + return None; + } + list.iter() + .map(|q| { + let Value::Descriptor(q) = q else { return None }; + let Some(Value::List(ix)) = q.get("indices") else { return None }; + let ix: Vec = ix + .iter() + .map(|i| match i { + Value::Integer(i) => usize::try_from(*i).ok(), + _ => None, + }) + .collect::>()?; + ix.try_into().ok() + }) + .collect() +} + +/// The planes a `perspectiveWarpTransform` descriptor describes; `None` when it is inconsistent. +fn planes_of(f: &Descriptor) -> Option> { + let (src, dst) = (points(f, "vertices")?, points(f, "warpedVertices")?); + if src.len() != dst.len() { + return None; + } + let corners = + |list: &[[f64; 2]], q: &[usize; 4]| -> Option<[[f64; 2]; 4]> { Some([*list.get(q[0])?, *list.get(q[1])?, *list.get(q[2])?, *list.get(q[3])?]) }; + let planes: Vec = quads(f)?.iter().map(|q| Some(Plane { src: corners(&src, q)?, dst: corners(&dst, q)? })).collect::>()?; + PerspectiveMap::new(&planes)?; + Some(planes) +} + +/// `layer.smartObjects.perspectiveWarp` params for a Perspective Warp item. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let planes = planes_of(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + // Export refuses a larger template; such an item stays opaque, so it is still written back. + let bytes = template.to_bytes(); + if bytes.len() > MAX_TEMPLATE_BYTES { + return None; + } + Some(json!({"planes": planes, TEMPLATE_KEY: to_hex(&bytes)})) +} + +/// The template item of `params` (`None` when there is none), checked to be a Perspective Warp. +fn template(params: &J) -> Result, String> { + let Some(raw) = params.get(TEMPLATE_KEY) else { return Ok(None) }; + let raw = raw.as_str().ok_or("the Perspective Warp PSD data must be hex text")?; + if raw.len() > MAX_TEMPLATE_BYTES * 2 { + return Err("the Perspective Warp PSD data is too large".into()); + } + let bytes = from_hex(raw).ok_or("the Perspective Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Perspective Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Perspective Warp PSD data is not a Perspective Warp filter".into()); + } + Ok(Some(item)) +} + +/// The planes to write: as rendered, so a `straighten` is baked in. +fn export_planes(params: &J) -> Result, String> { + let object = params.as_object().ok_or("Perspective Warp parameters must be an object")?; + if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["planes", "straighten", "interpolation"].contains(&k.as_str())) { + return Err(format!("Perspective Warp setting `{k}` has no PSD equivalent")); + } + let mut planes: Vec = + serde_json::from_value(params.get("planes").cloned().ok_or("it has no planes")?).map_err(|e| format!("its planes are unreadable ({e})"))?; + if planes.is_empty() || planes.len() > MAX_QUADS { + return Err(format!("it must have 1 to {MAX_QUADS} planes")); + } + if planes.iter().any(|q| q.src.iter().chain(&q.dst).flatten().any(|v| !v.is_finite())) { + return Err("a plane corner is not finite".into()); + } + if let Some(s) = params.get("straighten").and_then(J::as_str) { + straighten(&mut planes, Straighten::parse(s).ok_or_else(|| format!("unknown straighten `{s}`"))?); + } + PerspectiveMap::new(&planes).ok_or("a plane is degenerate")?; + Ok(planes) +} + +fn pnt(p: [f64; 2]) -> Value { + Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Pxl", p[0])).with("Vrtc", unit(b"#Pxl", p[1]))) +} + +/// `vertices`, `warpedVertices` and `quads` for `planes`; a corner shared by several planes (same +/// layout and warped position) is written once. +fn geometry(planes: &[Plane]) -> (Value, Value, Value) { + let mut corners: Vec<([f64; 2], [f64; 2])> = Vec::new(); + let mut quads = Vec::with_capacity(planes.len()); + for p in planes { + let mut ix = Vec::with_capacity(4); + for (s, d) in p.src.iter().zip(&p.dst) { + let i = corners.iter().position(|c| c == &(*s, *d)).unwrap_or_else(|| { + corners.push((*s, *d)); + corners.len() - 1 + }); + ix.push(Value::Integer(i32::try_from(i).unwrap_or(i32::MAX))); + } + quads.push(Value::Descriptor(Descriptor::new("perspectiveWarpQuad").with("indices", Value::List(ix)))); + } + let src = corners.iter().map(|c| pnt(c.0)).collect(); + let dst = corners.iter().map(|c| pnt(c.1)).collect(); + (Value::List(src), Value::List(dst), Value::List(quads)) +} + +/// A new `perspectiveWarpTransform` descriptor (no Photoshop template to start from). +fn fresh_fltr(planes: &[Plane]) -> Descriptor { + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for c in planes.iter().flat_map(|p| p.src) { + (x0, y0, x1, y1) = (x0.min(c[0]), y0.min(c[1]), x1.max(c[0]), y1.max(c[1])); + } + let target = Value::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::new_nul(""), class_id: Id::new("Lyr ") }, + type_id: Id::new("Ordn"), + value: Id::new("Trgt"), + }]); + let rect = + Descriptor::new("Rctn").with("Top ", unit(b"#Pxl", y0)).with("Left", unit(b"#Pxl", x0)).with("Btom", unit(b"#Pxl", y1)).with("Rght", unit(b"#Pxl", x1)); + Descriptor::new(CLASS).with("null", target).with("referenceRect", Value::Descriptor(rect)) +} + +/// The `filterFXList` item for a Perspective Warp smart filter. +pub(super) fn export_item(sf: &SmartFilter) -> Result { + let params = &sf.params; + let planes = export_planes(params)?; + let template = template(params)?; + let unchanged = template.as_ref().and_then(|item| get_desc(item, "Fltr")).and_then(planes_of).is_some_and(|p| p == planes); + let mut item = match template { + // Unchanged planes: the original item, vertex order and sharing included. + Some(item) if unchanged => item, + Some(mut item) => { + let mut f = get_desc(&item, "Fltr").cloned().ok_or("the Perspective Warp PSD data has no filter")?; + write_geometry(&mut f, &planes); + set(&mut item, "Fltr", Value::Descriptor(f)); + item + } + None => { + let mut f = fresh_fltr(&planes); + write_geometry(&mut f, &planes); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(NAME))) + .with("blendOptions", blend_options(sf)) + .with("enab", Value::Boolean(sf.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(FILTER_ID)) + } + }; + set(&mut item, "blendOptions", blend_options(sf)); + set(&mut item, "enab", Value::Boolean(sf.visible)); + Ok(item) +} + +fn write_geometry(f: &mut Descriptor, planes: &[Plane]) { + let (src, dst, quads) = geometry(planes); + set(f, "vertices", src); + set(f, "warpedVertices", dst); + set(f, "quads", quads); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, int, item_for_filter}; + + fn pts(list: &[[f64; 2]]) -> Value { + Value::List(list.iter().map(|&p| pnt(p)).collect()) + } + + fn quad_list>(q: &[Q]) -> Value { + Value::List( + q.iter() + .map(|q| { + Value::Descriptor( + Descriptor::new("perspectiveWarpQuad").with("indices", Value::List(q.as_ref().iter().map(|&i| Value::Integer(i)).collect())), + ) + }) + .collect(), + ) + } + + /// A `filterFXList` item as Photoshop wrote it for a one-plane Perspective Warp of a smart + /// object placed at (40, 30)–(160, 120). + fn item_with>(vertices: &[[f64; 2]], warped: &[[f64; 2]], q: &[Q], filter_id: i32) -> Descriptor { + let mut f = fresh_fltr(&[Plane::identity([[40.0, 30.0], [160.0, 30.0], [160.0, 120.0], [40.0, 120.0]])]) + .with("vertices", pts(vertices)) + .with("warpedVertices", pts(warped)) + .with("quads", quad_list(q)); + for (i, (x, y)) in [(40.0, 30.0), (160.0, 30.0), (160.0, 120.0), (40.0, 120.0)].into_iter().enumerate() { + f = f.with(&format!("PuX{i}"), Value::Double(x)); + f = f.with(&format!("PuY{i}"), Value::Double(y)); + } + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Perspektivische Verformung..."))) + .with( + "blendOptions", + blend_options(&SmartFilter { + command: String::new(), + params: J::Null, + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }), + ) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(filter_id)) + } + + const V: [[f64; 2]; 4] = [[40.0, 30.0], [160.0, 30.0], [160.0, 120.0], [40.0, 120.0]]; + const W: [[f64; 2]; 4] = [[60.0, 50.0], [140.0, 40.0], [180.0, 130.0], [20.0, 110.0]]; + + fn one_plane() -> Descriptor { + item_with(&V, &W, &[[0, 1, 2, 3]], FILTER_ID) + } + + /// Two planes sharing an edge (vertices 1 and 4). + fn two_planes(filter_id: i32) -> Descriptor { + let v = [[40.0, 30.0], [100.0, 30.0], [160.0, 30.0], [160.0, 120.0], [100.0, 120.0], [40.0, 120.0]]; + let w = [[40.0, 40.0], [100.0, 30.0], [170.0, 45.0], [150.0, 115.0], [100.0, 125.0], [30.0, 110.0]]; + item_with(&v, &w, &[[0, 1, 4, 5], [1, 2, 3, 4]], filter_id) + } + + #[test] + fn a_perspective_warp_opens_as_the_command_with_its_planes() { + let f = filter_from_item(&one_plane()); + assert_eq!(f.command, COMMAND); + let planes: Vec = serde_json::from_value(f.params["planes"].clone()).unwrap(); + assert_eq!(planes, vec![Plane { src: V, dst: W }]); + + // Several quads become several planes; any filterID (708 in other versions) is read. + let f = filter_from_item(&two_planes(708)); + assert_eq!(f.command, COMMAND); + let planes: Vec = serde_json::from_value(f.params["planes"].clone()).unwrap(); + assert_eq!(planes.len(), 2); + assert_eq!(planes[0].src[1], planes[1].src[0], "the shared vertex is the same corner in both planes"); + assert_eq!(planes[1].dst, [[100.0, 30.0], [170.0, 45.0], [150.0, 115.0], [100.0, 125.0]]); + } + + #[test] + fn unchanged_filters_write_the_original_item_back() { + for src in [one_plane(), two_planes(708)] { + let f = filter_from_item(&src); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn edited_planes_round_trip_and_keep_the_rest_of_the_template() { + let src = two_planes(708); + let mut f = filter_from_item(&src); + f.params["planes"][1]["dst"][2] = json!([155.5, 118.0]); + f.opacity = 0.5; + f.visible = false; + let item = item_for_filter(&f).unwrap(); + assert_eq!(int(&item, "filterID"), Some(708), "the original filter id stays"); + let fltr = get_desc(&item, "Fltr").unwrap(); + assert_eq!(fltr.get("PuX1"), Some(&Value::Double(160.0)), "unmodelled keys stay"); + let Some(Value::List(v)) = fltr.get("vertices") else { panic!() }; + assert_eq!(v.len(), 6, "shared corners stay shared"); + let back = filter_from_item(&item); + assert_eq!(back.command, COMMAND); + assert_eq!(back.params["planes"], f.params["planes"]); + assert_eq!((back.opacity, back.visible), (0.5, false)); + } + + #[test] + fn a_new_perspective_warp_writes_photoshops_layout() { + let planes = vec![Plane { src: V, dst: W }]; + let f = SmartFilter { + command: COMMAND.into(), + params: json!({"planes": planes}), + blend: photocraft_color::BlendMode::Multiply, + opacity: 0.25, + visible: true, + }; + let item = item_for_filter(&f).unwrap(); + assert_eq!(int(&item, "filterID"), Some(FILTER_ID)); + assert_eq!(item.get("hasoptions"), Some(&Value::Boolean(true))); + let fltr = get_desc(&item, "Fltr").unwrap(); + assert!(fltr.class_id.is(CLASS)); + assert_eq!(fltr.get("vertices"), Some(&pts(&V))); + assert_eq!(fltr.get("warpedVertices"), Some(&pts(&W))); + assert_eq!(fltr.get("quads"), Some(&quad_list(&[[0, 1, 2, 3]]))); + let rect = get_desc(fltr, "referenceRect").unwrap(); + assert_eq!(rect.get("Rght"), Some(&unit(b"#Pxl", 160.0))); + let back = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((back.command.as_str(), &back.params["planes"]), (COMMAND, &json!(planes))); + assert_eq!((back.blend, back.opacity), (photocraft_color::BlendMode::Multiply, 0.25)); + + // Edit › Perspective Warp on a smart object stores its own id; it is written too, with a + // straighten baked in. + let f = SmartFilter { command: EDIT_COMMAND.into(), params: json!({"planes": planes, "straighten": "auto", "interpolation": "bilinear"}), ..f }; + assert!(item_for_filter(&f).is_ok()); + } + + #[test] + fn inconsistent_perspective_warps_stay_opaque() { + let nan = [[f64::NAN, 30.0], V[1], V[2], V[3]]; + let flat = [[0.0, 0.0]; 4]; + let without = |key: &str| { + let mut i = one_plane(); + let mut f = get_desc(&i, "Fltr").unwrap().clone(); + f.items.retain(|(k, _)| !k.is(key)); + set(&mut i, "Fltr", Value::Descriptor(f)); + i + }; + let mut bad_unit = one_plane(); + let mut f = get_desc(&bad_unit, "Fltr").unwrap().clone(); + let Some(Value::List(l)) = f.get("vertices").cloned() else { panic!() }; + let mut l = l; + l[0] = Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", 40.0)).with("Vrtc", unit(b"#Prc", 30.0))); + set(&mut f, "vertices", Value::List(l)); + set(&mut bad_unit, "Fltr", Value::Descriptor(f)); + let too_many: Vec<[i32; 4]> = vec![[0, 1, 2, 3]; MAX_QUADS + 1]; + // Valid planes, but a template export would refuse as too large. + let mut huge = one_plane(); + huge.items.push((Id::new("bigData"), Value::RawData(vec![0; MAX_TEMPLATE_BYTES]))); + for (i, src) in [ + item_with(&V, &W, &[[0, 1, 2, 4]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2, -1]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2]], FILTER_ID), + item_with(&V, &W, &[[0, 1, 2, 3, 0]], FILTER_ID), + item_with(&V, &W, &[] as &[[i32; 4]], FILTER_ID), + item_with(&V, &W, &too_many, FILTER_ID), + item_with(&V, &W[..3], &[[0, 1, 2, 3]], FILTER_ID), + item_with(&nan, &W, &[[0, 1, 2, 3]], FILTER_ID), + item_with(&flat, &W, &[[0, 1, 2, 3]], FILTER_ID), + item_with(&[], &[], &[[0, 1, 2, 3]], FILTER_ID), + without("vertices"), + without("warpedVertices"), + without("quads"), + bad_unit, + huge, + ] + .into_iter() + .enumerate() + { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER, "case {i}"); + // Bytes, as NaN is not equal to itself. + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), src.to_bytes()); + } + } + + #[test] + fn bad_exports_report_errors() { + let ok = filter_from_item(&one_plane()).params; + let with = |k: &str, v: J| { + let mut p = ok.clone(); + p[k] = v; + p + }; + let plane = json!([{"src": V, "dst": W}]); + for p in [ + J::Null, + json!({}), + json!({"planes": []}), + json!({"planes": "x"}), + json!({"planes": [{"src": [[0, 0], [0, 0], [0, 0], [0, 0]], "dst": W}]}), + json!({"planes": plane, "straighten": "sideways"}), + json!({"planes": plane, "pins": []}), + json!({"planes": vec![json!({"src": V, "dst": W}); MAX_QUADS + 1]}), + with(TEMPLATE_KEY, json!("zz")), + with(TEMPLATE_KEY, json!(7)), + with(TEMPLATE_KEY, json!(to_hex(&Descriptor::new("filterFX").to_bytes()))), + with(TEMPLATE_KEY, json!("0".repeat(MAX_TEMPLATE_BYTES * 2 + 2))), + ] { + let f = SmartFilter { command: COMMAND.into(), params: p.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + assert!(export_item(&f).is_err(), "{p}"); + } + } +} diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 0167eb3b3f..0251f83755 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -43,40 +43,43 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl let ch = planes.len(); let row = ch * bps; let mut out = vec![0u8; n * row]; - // One band of rows per core, written straight into its slice of the shared output: the - // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations - // instead of all of them. + if out.is_empty() { + return out; + } let bs = bands(n); - let parts: Vec> = par_map(bs.clone(), |band| { - let mut part = vec![0u8; band.len() * row]; - for (i, p) in band.clone().enumerate() { - for (c, plane) in planes.iter().enumerate() { - let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; - match plane { - Some(pl) if pl.len() >= (p + 1) * bps => { - let src = &pl[p * bps..(p + 1) * bps]; - for k in 0..bps { - dst[k] = src[bps - 1 - k]; - } - if cfg!(target_endian = "big") { - dst.copy_from_slice(src); - } - if invert[c] { - invert_sample(dst, s); - } - } - _ => dst.copy_from_slice(&fill[c]), - } + let step = bs.first().map_or(n, |b| b.len()) * row; + par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| { + for (c, plane) in planes.iter().enumerate() { + let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default(); + let inv = src.len() >= bps && invert[c]; + let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default(); + match s { + SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]), + SampleType::U16 => write_channel(part, ch, c, src, fill, |v| { + let v = u16::from_be_bytes(v); + (if inv { 65535 - v } else { v }).to_ne_bytes() + }), + SampleType::F32 => write_channel(part, ch, c, src, fill, |v| { + // Preserve float bits when no inversion is requested. + if inv { (1.0 - f32::from_be_bytes(v)).to_ne_bytes() } else { u32::from_be_bytes(v).to_ne_bytes() } + }), } } - part }); - for (band, part) in bs.into_iter().zip(parts) { - out[band.start * row..band.end * row].copy_from_slice(&part); - } out } +fn write_channel(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) { + let mut samples = src.as_chunks::().0.iter(); + for pixel in out.chunks_exact_mut(ch * N) { + let dst = &mut pixel[c * N..(c + 1) * N]; + match samples.next() { + Some(sample) => dst.copy_from_slice(&decode(*sample)), + None => dst.copy_from_slice(fill), + } + } +} + /// Splits interleaved native-endian bytes into big-endian planes. pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); @@ -217,21 +220,29 @@ pub fn bands(n: usize) -> Vec> { mod tests { use super::*; - #[test] - fn banded_shuffles_match_a_sequential_reference() { - fn seq_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { - let bps = s.bytes(); - let ch = planes.len(); - let mut out = vec![0u8; n * ch * bps]; - for (c, plane) in planes.iter().enumerate() { - for i in 0..n { - let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + pub(super) fn previous_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + let bps = s.bytes(); + let ch = planes.len(); + let row = ch * bps; + let mut out = vec![0u8; n * row]; + // One band of rows per core, written straight into its slice of the shared output: the + // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations + // instead of all of them. + let bs = bands(n); + let parts: Vec> = par_map(bs.clone(), |band| { + let mut part = vec![0u8; band.len() * row]; + for (i, p) in band.clone().enumerate() { + for (c, plane) in planes.iter().enumerate() { + let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; match plane { - Some(p) if p.len() >= (i + 1) * bps => { - let src = &p[i * bps..(i + 1) * bps]; + Some(pl) if pl.len() >= (p + 1) * bps => { + let src = &pl[p * bps..(p + 1) * bps]; for k in 0..bps { dst[k] = src[bps - 1 - k]; } + if cfg!(target_endian = "big") { + dst.copy_from_slice(src); + } if invert[c] { invert_sample(dst, s); } @@ -240,8 +251,16 @@ mod tests { } } } - out + part + }); + for (band, part) in bs.into_iter().zip(parts) { + out[band.start * row..band.end * row].copy_from_slice(&part); } + out + } + + #[test] + fn banded_shuffles_match_a_sequential_reference() { fn seq_deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); let n = bytes.len() / (ch * bps).max(1); @@ -260,16 +279,17 @@ mod tests { planes } // Odd pixel counts cross band boundaries; a missing plane exercises the fill path. - for n in [1usize, 7, 64, 1000] { + for n in [0usize, 1, 7, 64, 1000] { for s in [SampleType::U8, SampleType::U16, SampleType::F32] { let bps = s.bytes(); let planes: Vec> = (0..4).map(|c| (0..n * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); let mut refs: Vec> = planes.iter().map(|p| Some(&p[..])).collect(); refs[2] = None; + refs[3] = Some(&planes[3][..(n * bps).saturating_sub(1)]); let fill: Vec> = (0..4).map(|c| vec![c as u8 * 40; bps]).collect(); let invert = [false, true, false, true]; let got = interleave(&refs, &fill, n, s, &invert); - let want = seq_interleave(&refs, &fill, n, s, &invert); + let want = previous_interleave(&refs, &fill, n, s, &invert); assert_eq!(got, want, "interleave n={n}"); let planes_back = deinterleave(&got, 4, s, &invert); let want_back = seq_deinterleave(&got, 4, s, &invert); @@ -287,6 +307,7 @@ mod tests { let fill = vec![zero_sample(s); 3]; let inv = [false, true, false]; let il = interleave(&refs, &fill, 4, s, &inv); + assert_eq!(interleave(&refs, &[], 4, s, &inv), il); let back = deinterleave(&il, 3, s, &inv); if s == SampleType::F32 { // 1 - (1 - v) may not be exact for floats; only check non-inverted planes. @@ -302,6 +323,12 @@ mod tests { fn missing_planes_use_fill() { let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]); assert_eq!(il, vec![9, 5, 9, 6]); + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + for n in [1, 1000] { + let fill = vec![vec![9; s.bytes()]; 2]; + assert_eq!(interleave(&[None, Some(&[1, 2, 3][..s.bytes() - 1])], &fill, n, s, &[]), vec![9; n * 2 * s.bytes()]); + } + } } #[test] @@ -343,3 +370,35 @@ mod tests { assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); } } + +#[cfg(test)] +mod measurement { + use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_interleave() { + use std::{hint::black_box, time::Instant}; + const N: usize = 24_000_000; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let bps = sample.bytes(); + let planes: Vec> = (0..4).map(|c| (0..N * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); + let refs: Vec<_> = planes.iter().map(|p| Some(p.as_slice())).collect(); + let fill = vec![vec![0; bps]; 4]; + let invert = [false, true, false, true]; + assert_eq!(tests::previous_interleave(&refs, &fill, N, sample, &invert), interleave(&refs, &fill, N, sample, &invert)); + for trial in 0..6 { + let mut paths = [("main", tests::previous_interleave as fn(_, _, _, _, _) -> _), ("split", interleave)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&refs, &fill, N, sample, &invert); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("interleave,{sample:?},{trial},{name},{elapsed:.6}"); + } + } + } + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..90a5d86ce9 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -29,6 +29,12 @@ const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; 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. + 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 /// with its own alpha, such as a layered TIFF). @@ -41,11 +47,21 @@ pub struct PsdExportOptions { /// readers that only show the merged image see white. A document without layer records always /// gets the real image, since its pixels live nowhere else. pub maximize_compatibility: bool, + /// The path of the document's own file: linked smart objects are resolved against it + /// ([`crate::doc_path::resolve`]) before they are read and embedded. + pub document_path: Option, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } + PsdExportOptions { + force_psb: false, + size_limit_psb: true, + merged_matte: true, + merged_compression: Compression::Rle, + maximize_compatibility: true, + document_path: None, + } } } @@ -53,6 +69,8 @@ struct Ex { fmt: PixelFormat, /// Document resolution (for generated type-layer data). dpi: f32, + /// The document's own file, which linked smart objects are resolved against. + document: Option, /// Character/paragraph styles written into every type layer's engine data. text_styles: photocraft_doc::TextStyles, mask_fmt: PixelFormat, @@ -102,9 +120,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written @@ -439,7 +455,11 @@ impl Ex { self.warnings.extend(warnings); let mut mask = MaskData::None; // Vector mask density/feather live in the mask parameters, with or without a user mask. - let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); + // A shape layer's path is its vector mask, with the shape's own density and feather. + let vector = match &l.content { + LayerContent::Shape(sh) if l.vector_mask.is_none() => Some((sh.density, sh.feather)), + _ => l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)), + }; if let Some(m) = &l.mask { let (md, ch) = self.mask(m, vector); mask = md; @@ -591,16 +611,29 @@ impl Ex { // It parses here, but Photoshop requires four-byte alignment, so repair // that legacy record on re-save rather than preserving the broken bytes. let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0; - !legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a + // Older PhotoCraft builds wrote `grdm` 2 bytes short of four-byte alignment, + // which makes Photoshop refuse the file (#8070): regenerate it too. + let legacy_grdm = k == b"grdm" && adjust_map::is_unpadded_photocraft_gradient_map(d); + !legacy_phfl && !legacy_grdm && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a }); // A Gradient Map whose stops are unchanged keeps the file's own block (stops in // the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither // updated in place. let keep = keep || (matches!(a, photocraft_doc::Adjustment::GradientMap { .. }) - && raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels))); + && raw.iter_mut().any(|(k, d)| { + k == b"grdm" && !adjust_map::is_unpadded_photocraft_gradient_map(d) && adjust_map::retarget_gradient_map(d, a, channels) + })); + // An adjustment type no mapper knows (#6575) is its block: kept verbatim, and + // never written a second time beside a stale copy. + let own = match a { + photocraft_doc::Adjustment::Unsupported { psd_key, .. } => Some(psd_key.as_bytes()), + _ => None, + }; + let keep = + keep || matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d == r)); if !keep { - raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); + raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && own != Some(&k[..])); let w = adjust_map::write(a); if w.is_empty() { self.warnings.push(format!("layer \"{}\": {} adjustment is not yet written to PSD", l.name, a.label())); @@ -698,9 +731,24 @@ impl Ex { let id = self.layer_ids.get(&l.id).copied().unwrap_or(0); crate::comps_map::set_cmls(&mut raw, crate::comps_map::write_cmls(comps, last.as_ref(), l, id)); } + self.layer_xmp_block(l, &mut raw); regenerated.into_iter().chain(raw).map(|(k, d)| TaggedBlock::new(k, d)).collect() } + /// Writes the layer's XMP packet into its `shmd` block (created when it has none). Import + /// takes the packet out of `shmd`, so a layer without one keeps the block as it was. + fn layer_xmp_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec)>) { + let Some(xmp) = l.xmp.as_deref() else { return }; + let pos = raw.iter().position(|(k, _)| k == b"shmd"); + match photocraft_psd::metadata::put_layer_xmp(pos.map(|i| raw[i].1.as_slice()), xmp) { + Ok(data) => match pos { + Some(i) => raw[i].1 = data, + None => raw.push((*b"shmd", data)), + }, + Err(e) => self.warnings.push(format!("layer \"{}\": its XMP metadata was not saved (its metadata block can't be read: {e})", l.name)), + } + } + /// Keeps, regenerates or removes the `vmsk`/`vsms` block of a non-shape layer. fn vector_mask_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec)>) { let (w, h) = (self.canvas.width(), self.canvas.height()); @@ -929,7 +977,8 @@ impl Ex { SmartSource::Linked { path } => { #[cfg(not(target_arch = "wasm32"))] { - let bytes = std::fs::read(path).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; + let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?; + let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); self.embed(sm.contents_id, &name, &bytes) @@ -998,8 +1047,12 @@ impl Ex { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; - let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); + let (file, warnings) = document_to_psd_nested( + &doc, + &PsdExportOptions { force_psb: true, document_path: self.document.clone(), ..Default::default() }, + self.smart.depth + 1, + &Interrupt::NONE, + ); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1253,6 +1306,10 @@ pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bo if !crate::annotations_map::keep_resource(doc, id) { return false; } + // Guides v2 and guide GUIDs: only while they still describe the document's guides. + if !crate::guides_map::keep_resource(doc, id, data) { + return false; + } // A preserved clipping-path resource is only valid while it names the current one. if id == crate::vector_map::CLIPPING_PATH { let n = usize::from(data.first().copied().unwrap_or(0)); @@ -1491,9 +1548,16 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option { /// 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 -/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). -fn block_signature(sig: [u8; 4], version: Version) -> [u8; 4] { - if version == Version::Psd && sig == *b"8B64" { *b"8BIM" } else { sig } +/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). The other way +/// round, a PSB block with an 8-byte length is `8B64`, as Photoshop writes it: an `8BIM` `lnk2`, +/// `pths` or `cinf` (kept from a PSD source, whose lengths the PSB widens) makes Photoshop read +/// the length as 4 bytes and refuse the whole file (#7937). +pub(crate) fn block_signature(sig: [u8; 4], key: [u8; 4], version: Version) -> [u8; 4] { + match version { + Version::Psd if sig == *b"8B64" => *b"8BIM", + Version::Psb if sig == *b"8BIM" && photocraft_psd::tagged::uses_long_length(version, &key) => *b"8B64", + _ => sig, + } } pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version { @@ -1510,15 +1574,27 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } + if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) { + return file; + } let fmt = doc.pixel_format(); + // 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. + let duotone_inks: Option> = doc + .duotone + .as_ref() + .filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample == SampleType::U8) + .map(|d| d.psd_raw.clone().unwrap_or_else(|| crate::duotone_map::write(d))); 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.force_psb && !big && psd_size_exceeds_limit(doc); + 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 mut ex = Ex { fmt, dpi: doc.resolution_dpi, + document: opts.document_path.clone(), text_styles: doc.text_styles.clone(), text_index: Default::default(), mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false), @@ -1542,7 +1618,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } 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 { + 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 @@ -1571,26 +1647,36 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c 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 the next free number. + // 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(); - let mut next_txt = 0i32; ex.text_index = Default::default(); + let mut fresh = Vec::new(); for (_, _, l) in &walk { let LayerContent::Text(t) = &l.content else { continue; }; - let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)); - let index = match kept { - Some(i) if used_txt.insert(i) => i, - _ => { - while used_txt.contains(&next_txt) { - next_txt += 1; - } - used_txt.insert(next_txt); - next_txt + 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); } - }; - ex.text_index.insert(l.id, index); + _ => 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); } } // Progress: one unit per layer record, then the merged composite (#3389). @@ -1608,8 +1694,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // 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. - let real_merged = opts.maximize_compatibility || ex.records.is_empty(); + // 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 @@ -1653,7 +1740,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; - let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); + 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)) @@ -1719,8 +1807,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the // block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the // one mode EngineData can't carry; #1348). - let txt2 = - (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref())); + let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)) + .then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi)); for (sig, key, data) in preserved { if txt2.is_some() && key == *b"Txt2" { continue; @@ -1731,7 +1819,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - global_blocks.push(global_block(block_signature(sig, version), key, &data)); + global_blocks.push(global_block(block_signature(sig, key, version), key, &data)); } if let Some(txt2) = txt2 { let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone()); @@ -1796,7 +1884,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } // 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not // open a 32-bit file without them (#291). - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let color_mode_data = match duotone_inks { + Some(raw) => raw, + None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), + }; let file = PsdFile { header, color_mode_data, @@ -1815,7 +1906,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c /// every value is the default (density 1, feather 0). fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option { let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d))); - let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f))); + let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f.is_finite() && f > 0.0).then_some(f64::from(f))); let p = MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) }; let flags = u8::from(p.user_density.is_some()) @@ -1870,8 +1961,14 @@ mod tests { let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; let bg = &doc.layers[0]; assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); + // Bitmap has no layers in Photoshop (#4814): the file is saved flat in Bitmap mode, without + // a layer section, and opens as a Background again. let out = document_to_psd(&doc); - assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0)); + assert_eq!(out.header.color_mode, PsdMode::Bitmap); + assert!(out.layer_info.as_ref().is_none_or(|li| li.layers.is_empty())); + let back = crate::import("flat.psd", &out.to_bytes().unwrap()).unwrap().document; + let bg = &back.layers[0]; + assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } #[test] @@ -2051,6 +2148,35 @@ mod tests { assert_eq!(signatures(true), vec![(*b"8B64", *b"FMsk"), (*b"8BIM", *b"Patt")], "a PSB keeps 8B64"); } + /// A PSB writes every global block with an 8-byte length as `8B64`, the way Photoshop does: + /// kept from a PSD source, `lnk2`, `pths` and `cinf` arrive as `8BIM`, and with that signature + /// and the PSB's 8-byte length Photoshop 27.11 refused the whole file (#7937). Blocks with a + /// 4-byte length keep `8BIM`, and a PSD keeps `8BIM` throughout. + #[test] + fn psb_writes_long_length_blocks_as_8b64() { + let mut doc = document(4, 4); + 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"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), + ]; + let written = |force_psb| { + let opts = PsdExportOptions { force_psb, ..PsdExportOptions::default() }; + let bytes = document_to_psd_with(&doc, &opts).0.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let blocks = file.global_blocks.iter().map(|b| (b.signature, b.key, b.data.len())).collect::>(); + (bytes, blocks) + }; + let (bytes, blocks) = written(true); + assert_eq!(blocks, vec![(*b"8B64", *b"cinf", 8), (*b"8B64", *b"pths", 8), (*b"8BIM", *b"Patt", 0)]); + // On disk: the signature, the key, then the 8-byte length. + let at = bytes.windows(8).position(|w| w == b"8B64cinf").expect("8B64cinf in the file"); + assert_eq!(bytes.get(at + 8..at + 16), Some(&[0u8, 0, 0, 0, 0, 0, 0, 8][..])); + assert!(!bytes.windows(8).any(|w| w == b"8BIMcinf" || w == b"8BIMpths")); + let (_, blocks) = written(false); + assert_eq!(blocks, vec![(*b"8BIM", *b"cinf", 8), (*b"8BIM", *b"pths", 8), (*b"8BIM", *b"Patt", 0)], "a PSD keeps 8BIM"); + } + /// A layered document with transparency in the merged image and an alpha channel: the name /// and DisplayInfo resources hold one entry per extra channel, the merged transparency first, /// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 12307f32ba..bf4707bce7 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -88,9 +88,19 @@ fn selected_real_mask(rec: &LayerRecord) -> Option { rec.layer_mask()?.real } +/// The (density, feather) a shape layer's mask parameters give its path, which is its vector +/// mask: (1, 0) without them. +fn shape_mask_params(rec: &LayerRecord) -> (f32, f32) { + let p = rec.layer_mask().and_then(|m| m.parameters); + let density = p.and_then(|p| p.vector_density).map_or(1.0, |d| f32::from(d) / 255.0); + let feather = p.and_then(|p| p.vector_feather).filter(|f| f.is_finite()).map_or(0.0, |f| (f as f32).clamp(0.0, 1000.0)); + (density, feather) +} + /// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer -/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a -/// feather (`.1` true), or when it fills with a pattern. +/// with a vector mask: when it fills with a pattern, which the shape renderer can't draw (`.1` +/// true when its vector mask is also soft: a density below 100 % or a feather). Other soft +/// shapes stay shapes with that density and feather. fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<(photocraft_doc::Fill, bool)> { if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() { return None; @@ -104,7 +114,7 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 // (our own export writes fill layers so, #1907): it is a pattern fill layer seen through // its vector mask. let pattern = matches!(fill, photocraft_doc::Fill::Pattern { .. }); - (soft || pattern).then_some((fill, soft)) + pattern.then_some((fill, soft)) } /// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. @@ -320,6 +330,19 @@ impl Ctx<'_> { // Blend If lives in the layer record's blending ranges. l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges); l.psd_id = rec.layer_id(); + // The layer's XMP packet (`shmd` › `cust` › `layerXMP`) becomes the field; the rest of + // `shmd` stays in `psd_blocks`, where export puts the packet back. + if let Some(i) = l.psd_blocks.iter().position(|(k, _)| k == b"shmd") + && let Some((xmp, rest)) = photocraft_psd::metadata::take_layer_xmp(&l.psd_blocks[i].1) + { + l.xmp = Some(Arc::from(xmp)); + match rest { + Some(data) => l.psd_blocks[i].1 = Arc::new(data), + None => { + l.psd_blocks.remove(i); + } + } + } let name = l.name.clone(); l.mask = self.record_mask(rec, &name); } @@ -361,28 +384,35 @@ impl Ctx<'_> { )); } LayerContent::Adjustment(adj) - } else if rec.block(b"TySh").is_some() { - // Typed model from TySh/EngineData (photocraft-text); Photoshop's pixels stay the cache. - let data = rec.block(b"TySh").map(|b| b.data.clone()).unwrap_or_default(); - let mut t = photocraft_text::psd::text_layer_from_tysh(&data, self.dpi).unwrap_or_else(|| { - let (text, transform) = blocks::parse_tysh(&data).unwrap_or_default(); - TextLayer { text, transform, ..Default::default() } - }); - if let Some(txt2) = &self.txt2 { - photocraft_text::psd::apply_txt2(&mut t, &data, txt2); - photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2); + } else if let Some(block) = rec.block(b"TySh") { + // Only canonical type data with a safe transform can become a text layer. A retained + // foreign-order block must never be handed to the big-endian reader (#4758). Canonical + // data the typed reader can't decode stays a type layer as before (usually without + // text), so its TySh is saved back verbatim instead of being dropped with the raster. + let parsed = (!block.is_foreign_order()) + .then(|| { + photocraft_text::psd::text_layer_from_tysh(&block.data, self.dpi).or_else(|| { + let (text, transform) = blocks::parse_tysh(&block.data).unwrap_or_default(); + photocraft_text::psd::transform_is_safe(&transform).then(|| TextLayer { text, transform, ..Default::default() }) + }) + }) + .flatten(); + if let Some(mut t) = parsed { + if let Some(txt2) = &self.txt2 { + photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2); + photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2); + } + // Photoshop's pixels stay until edited. Writers without type pixels are + // rendered by text_import::prepare instead of retaining an empty cache. + let cache = self.record_surface(rec, &name); + let drawn = !cache.content_bounds().is_empty() || photocraft_text::psd::engine_text_is_blank(&block.data); + t.cache = drawn.then_some(cache); + t.psd_raw = principal(b"TySh"); + LayerContent::Text(t) + } else { + self.warn(format!("layer \"{name}\": type data in another byte order or with unsafe type geometry; kept as pixels")); + LayerContent::Raster(self.record_surface(rec, &name)) } - // Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and - // other writers leave type layers without image data (Photoshop re-renders them on - // open), and an empty cache would show nothing until the layer is edited; the import - // renders such a layer from its model instead (`text_import::prepare`). When the - // engine text itself is blank, Photoshop draws nothing either: keep the empty pixels - // (corpus: text/path-wave-open.psd). - let cache = self.record_surface(rec, &name); - let drawn = !cache.content_bounds().is_empty() || photocraft_text::psd::engine_text_is_blank(&data); - t.cache = drawn.then_some(cache); - t.psd_raw = principal(b"TySh"); - LayerContent::Text(t) } 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), @@ -431,10 +461,9 @@ impl Ctx<'_> { perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { - // A shape whose vector mask has a density or feather is a fill layer seen through a - // soft vector mask: the fill shows beyond the path, which the stored pixels (the - // shape alone) lack. Import it as exactly that. A pattern fill keeps the stored - // pixels as its rendering (a soft mask's pixels lack the fill beyond the path). + // 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 + // mask's pixels lack the fill beyond the path). if !soft && matches!(f, photocraft_doc::Fill::Pattern { .. }) && !rec.rect.is_empty() { fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) }); } @@ -444,9 +473,13 @@ impl Ctx<'_> { let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() }; let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone()); crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi); + (sh.density, sh.feather) = shape_mask_params(rec); // No stored pixels (32-bit documents, files saved without layer pixels): render the - // shape from its path and fill, as Photoshop does when it opens the file. - if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) { + // shape from its path and fill, as Photoshop does when it opens the file. A soft + // shape's stored pixels hold the shape alone, without the fill its density and feather + // show beyond the path: render it too. + let soft = sh.has_soft_mask() && sh.fill.is_some(); + if soft || ((rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted)) { let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32); sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas)); } @@ -465,6 +498,15 @@ impl Ctx<'_> { LayerContent::Raster(self.record_surface(rec, &name)) } } + } else if let Some(k) = unknown_adjustment_key(rec) { + // An adjustment type no mapper knows: kept verbatim like an unreadable one, not + // opened as an empty pixel layer without a word (#6575). + let key = String::from_utf8_lossy(&k).into_owned(); + self.warn(format!( + "layer \"{name}\": its adjustment settings could not be read (unknown adjustment type \"{key}\"); the layer is kept as saved but has no effect" + )); + let raw = rec.block(&k).map(|b| b.data.clone()).unwrap_or_default(); + LayerContent::Adjustment(photocraft_doc::Adjustment::Unsupported { psd_key: key, raw }) } else { LayerContent::Raster(self.record_surface(rec, &name)) }; @@ -595,6 +637,33 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize { depth } +/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in +/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects, +/// masks, metadata, and the fill, type, placed and shape content read before. +const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [ + b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp", + b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd", + b"FMsk", b"PxSc", b"PxSD", b"cinf", b"ffxi", b"shpa", b"anFX", b"SoCo", b"GdFl", b"PtFl", b"TySh", b"tySh", b"SoLd", b"SoLE", b"PlLd", b"plLd", +]; + +/// The key of an adjustment layer whose type no mapper knows (#6575), e.g. Photoshop 4's +/// `hue ` or a type newer than PhotoCraft. Photoshop writes every adjustment layer without +/// pixels and with "pixel data irrelevant" set (every one in the corpus; pixel, type and smart +/// layers never have it). Such a record that is not a group and carries exactly one block that +/// is neither a known layer-information key nor a known adjustment is that adjustment. More than +/// one candidate is a guess, so the layer stays an (empty) pixel layer, as before; either way no +/// pixels can be lost and the block is saved back verbatim. +fn unknown_adjustment_key(rec: &LayerRecord) -> Option<[u8; 4]> { + let marked = rec.flags.bit4_useful() && rec.flags.pixel_data_irrelevant(); + if !marked || !rec.rect.is_empty() || rec.block(b"lsct").is_some() || rec.block(b"lsdk").is_some() { + return None; + } + let mut unknown = rec.blocks.iter().filter(|b| !LAYER_INFO_KEYS.contains(&&b.key) && !ADJUSTMENT_KEYS.contains(&&b.key)); + let only = unknown.next()?; + // A key that isn't four printable ASCII bytes names no adjustment (nor saves back as one). + (unknown.next().is_none() && only.key.iter().all(|c| c.is_ascii_graphic() || *c == b' ')).then_some(only.key) +} + fn preserved_blocks(rec: &LayerRecord) -> Vec<([u8; 4], Arc>)> { rec.blocks.iter().filter(|b| !REGENERATED.contains(&&b.key)).map(|b| (b.key, Arc::new(b.data.clone()))).collect() } @@ -686,7 +755,7 @@ fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { } } -fn parse_guides(data: &[u8], doc: &mut Document) { +pub(crate) fn parse_guides(data: &[u8], doc: &mut Document) { let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let Some(count) = rd(12) else { return }; for i in 0..count as usize { @@ -836,6 +905,7 @@ pub(crate) fn psd_to_document_decoded( 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()))); } @@ -1000,18 +1070,41 @@ pub(crate) fn psd_to_document_decoded( 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; - let colors = (0..256).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); - doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None }); + // 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 duotone ink block is undocumented: keep it raw; the image shows as its gray plate. - doc.duotone = - Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(file.color_mode_data.clone()) }); - cx.warn("duotone inks are not interpreted (shown as grayscale)"); + // 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.clone()) }); } 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(img) => { + 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); diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 008173839b..04158deddc 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -14,8 +14,9 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; +pub use photocraft_raw::Demosaic; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; -pub use photocraft_raw::{Tone, retone}; +pub use photocraft_raw::{EXTENSIONS, Tone, retone}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; @@ -34,9 +35,14 @@ fn limits() -> Limits { /// A raw file's embedded JPEG preview, turned upright. Its own EXIF orientation wins when it /// has one; otherwise the raw's IFD0 orientation applies (TIFF-based raws record it there, and /// their previews are usually stored as the sensor reads out). Developed raws are oriented by -/// `photocraft-raw` and carry no EXIF, so nothing is turned twice. -fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { - let img = codecs::decode_as_with(Format::Jpeg, jpeg, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?; +/// `photocraft-raw` and their EXIF carries no orientation, so nothing is turned twice. +fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> Result { + // Decode the embedded preview under the caller's limits, not the codec defaults: an + // embedded JPEG can declare far more decoded pixels than the raw development the caller + // authorized, so the preview fallback must honor the same policy. + let codec_limits = + codecs::Limits { max_width: limits.max_width, max_height: limits.max_height, max_pixels: limits.max_pixels, max_alloc: limits.max_alloc }; + let img = codecs::decode_as_with(Format::Jpeg, jpeg, &codecs::DecodeOptions { keep_orientation: true, limits: codec_limits })?; let own = img.meta.exif.as_deref().map_or(1, codecs::exif_orientation); let o = if own != 1 { own } else { photocraft_raw::orientation(raw).unwrap_or_else(|| codecs::exif_orientation(raw)) }; Ok(img.oriented(o)?) @@ -44,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels. +/// developed RGB pixels, and the demosaic method. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -53,6 +59,7 @@ pub struct RawTuning { pub tint: f32, /// EV, as `CameraRaw::exposure`. pub exposure: f32, + pub demosaic: Demosaic, /// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw /// does for raw files ([`photocraft_raw::Tone`]). pub shadows: f32, @@ -90,7 +97,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< // The colour rendering the default open used (a look fitted to the camera's JPEG for files // without colour calibration), so re-developing changes only what the sliders change. let camera_look = default_look(bytes, &sensor); - let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() }; + let mut opts = DevelopOptions { limits: limits(), exposure, demosaic: tuning.demosaic, tone, camera_look, ..Default::default() }; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); @@ -110,8 +117,8 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure); - Ok((developed_document(name, format, dev, &summary)?, wb_applied)) + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); + Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } /// The tone curve `bytes` develop with by default: the fitted look's for a file without colour @@ -164,8 +171,11 @@ fn wb_gains(temperature: f32, tint: f32) -> [f64; 3] { } /// A developed raw as a 16-bit ProPhoto document, with a note saying how it was developed. -fn developed_document(name: &str, format: &str, dev: photocraft_raw::Developed, summary: &str) -> Result { - let img = Image::from_u16(dev.width, dev.height, ChannelLayout::Rgb, &dev.rgb)?; +fn developed_document(name: &str, bytes: &[u8], format: &str, dev: photocraft_raw::Developed, summary: &str) -> Result { + let mut img = Image::from_u16(dev.width, dev.height, ChannelLayout::Rgb, &dev.rgb)?; + // The raw's own EXIF (camera, lens, exposure, date), for File Info's Camera Data. It carries + // no orientation: the developed image is already upright. + img.meta.exif = codecs::raw_exif(bytes); let mut r = image_to_document(name, &img)?; r.document.icc_profile = Some(Arc::new(photocraft_cms::Builtin::ProPhotoCompat.profile().to_bytes().to_vec())); let camera = camera_name(dev.info.make.as_deref(), dev.info.model.as_deref()); @@ -180,7 +190,12 @@ fn developed_document(name: &str, format: &str, dev: photocraft_raw::Developed, /// Develops a raw file with the default settings. pub fn import_raw(name: &str, bytes: &[u8]) -> Result { - import_raw_with(name, bytes, &DevelopOptions { limits: limits(), ..Default::default() }) + import_raw_demosaic(name, bytes, Demosaic::default()) +} + +/// Develops a raw file with the default settings and the `demosaic` method. +pub fn import_raw_demosaic(name: &str, bytes: &[u8], demosaic: Demosaic) -> Result { + import_raw_with(name, bytes, &DevelopOptions { limits: limits(), demosaic, ..Default::default() }) } /// Smallest preview long edge preferred for the colour fit (a smaller preview decodes faster; @@ -213,7 +228,7 @@ pub fn fit_preview_look(raw: &[u8], sensor: &Sensor, opts: &DevelopOptions) -> R .min_by_key(|p| long(p)) .or_else(|| previews.iter().max_by_key(|p| long(p))) .ok_or("no embedded JPEG preview")?; - let img = upright_preview(raw, pick.jpeg).map_err(|e| format!("the embedded preview does not decode: {e}"))?; + let img = upright_preview(raw, pick.jpeg, &opts.limits).map_err(|e| format!("the embedded preview does not decode: {e}"))?; if img.layout() != ChannelLayout::Rgb || img.sample_type() != SampleType::U8 { return Err("the embedded preview is not 8-bit RGB".into()); } @@ -246,6 +261,16 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result Option { + let note = warnings.iter().find(|w| w.contains(DEVELOPED_NOTE))?; + let after = ¬e[note.find(DEVELOPED_NOTE)? + DEVELOPED_NOTE.len()..]; + let id = after.trim_start().strip_prefix('(')?.split(" demosaic").next()?; + Demosaic::from_id(id) +} + /// Develops a raw file with explicit settings. Without `opts.camera_look`, a file without /// colour calibration gets one fitted to its embedded JPEG when that clearly helps. pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Result { @@ -253,11 +278,11 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul match develop_fitted(bytes, opts) { Ok(dev) => { let summary = format!("{DEVELOPED_NOTE} ({} demosaic, as-shot white balance)", opts.demosaic.id()); - developed_document(name, format, dev, &summary) + developed_document(name, bytes, format, dev, &summary) } Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) { Some(p) => { - let img = upright_preview(bytes, p.jpeg)?; + let img = upright_preview(bytes, p.jpeg, &opts.limits)?; let mut r = image_to_document(name, &img)?; r.warnings.insert( 0, @@ -278,6 +303,14 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul mod tests { use super::*; + #[test] + fn the_developed_note_says_which_demosaic_method_was_used() { + let note = format!("NEF: {DEVELOPED_NOTE} (mhc demosaic, as-shot white balance) into 16-bit ProPhoto RGB"); + assert_eq!(developed_demosaic(&["unrelated".to_string(), note]), Some(Demosaic::Mhc)); + assert_eq!(developed_demosaic(&[format!("{DEVELOPED_NOTE} (amaze demosaic)")]), None); + assert_eq!(developed_demosaic(&["opened the embedded preview".to_string()]), None); + } + #[test] fn camera_names_drop_a_repeated_make() { assert_eq!(camera_name(Some("Canon"), Some("Canon EOS 80D")), "Canon EOS 80D"); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3de336e16c..78b2d79201 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -20,11 +20,13 @@ //! listed, can be hidden, reordered or deleted, and is written back unchanged. //! //! Camera Raw and Displace keep their whole item as a template next to the editable settings -//! (Displace's carries the embedded displacement map, see `displace_map.rs`). +//! (Displace's carries the embedded displacement map, see `displace_map.rs`); so does Perspective +//! Warp (`perspective_warp_map.rs`), whose quads become the planes of its command. Hue/Saturation +//! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{LayerMask, SmartFilter}; -use photocraft_geom::warp::{Warp, WarpStyle}; +use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; use photocraft_psd::Compression; use photocraft_psd::Rect as PsdRect; @@ -39,8 +41,12 @@ use crate::blocks::{enum_of, get_desc, num}; mod camera_raw; #[path = "displace_map.rs"] mod displace; +#[path = "hue_sat_filter_map.rs"] +mod hue_sat; +#[path = "perspective_warp_map.rs"] +mod perspective_warp; -pub use displace::embedded_map as displace_embedded_map; +pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map}; /// Command id of a Photoshop smart filter PhotoCraft does not implement. Its params hold the /// filter's name, Photoshop filter id and descriptor (`psd`, hex); it renders as a pass-through. @@ -144,24 +150,118 @@ const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 14] = [ +const KNOWN: [Known; 45] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, + // Surface Blur, Smart Blur and Radial Blur: keys, value types and enums measured in Photoshop. + Known { class: "surfaceBlur", filter_id: 1135, name: "Surface Blur...", command: "filter.blur.surfaceBlur" }, + Known { class: "SmrB", filter_id: code(b"SmrB"), name: "Smart Blur...", command: "filter.blur.smartBlur" }, + // Radial Blur at its defaults stores no `Fltr` (`hasoptions` false); that entry is kept verbatim. + Known { class: "RdlB", filter_id: code(b"RdlB"), name: "Radial Blur...", command: "filter.blur.radialBlur" }, Known { class: "UnsM", filter_id: code(b"UnsM"), name: "Unsharp Mask...", command: "filter.sharpen.unsharpMask" }, Known { class: "AdNs", filter_id: code(b"AdNs"), name: "Add Noise...", command: "filter.noise.addNoise" }, Known { class: "Mdn ", filter_id: code(b"Mdn "), name: "Median...", command: "filter.noise.median" }, Known { class: "HghP", filter_id: code(b"HghP"), name: "High Pass...", command: "filter.other.highPass" }, Known { class: "Mxm ", filter_id: code(b"Mxm "), name: "Maximum...", command: "filter.other.maximum" }, Known { class: "Mnm ", filter_id: code(b"Mnm "), name: "Minimum...", command: "filter.other.minimum" }, + Known { class: "Ofst", filter_id: code(b"Ofst"), name: "Offset...", command: "filter.other.offset" }, + Known { class: "Cstm", filter_id: code(b"Cstm"), name: "Custom...", command: "filter.other.custom" }, Known { class: "Msc ", filter_id: code(b"Msc "), name: "Mosaic...", command: "filter.pixelate.mosaic" }, + Known { class: "Mztn", filter_id: code(b"Mztn"), name: "Mezzotint...", command: "filter.pixelate.mezzotint" }, + Known { class: "ClrH", filter_id: code(b"ClrH"), name: "Color Halftone...", command: "filter.pixelate.colorHalftone" }, Known { class: "Embs", filter_id: code(b"Embs"), name: "Emboss...", command: "filter.stylize.emboss" }, + Known { class: "Dfs ", filter_id: code(b"Dfs "), name: "Diffuse...", command: "filter.stylize.diffuse" }, + Known { class: "TrcC", filter_id: code(b"TrcC"), name: "Trace Contour...", command: "filter.stylize.traceContour" }, + Known { class: "Crst", filter_id: code(b"Crst"), name: "Crystallize...", command: "filter.pixelate.crystallize" }, + Known { class: "Pntl", filter_id: code(b"Pntl"), name: "Pointillize...", command: "filter.pixelate.pointillize" }, + Known { class: "Wnd ", filter_id: code(b"Wnd "), name: "Wind...", command: "filter.stylize.wind" }, + Known { class: "Extr", filter_id: code(b"Extr"), name: "Extrude...", command: "filter.stylize.extrude" }, + Known { class: "Tls ", filter_id: code(b"Tls "), name: "Tiles...", command: "filter.stylize.tiles" }, Known { class: "Lvls", filter_id: code(b"Lvls"), name: "Levels...", command: "image.adjustments.levels" }, Known { class: "Crvs", filter_id: code(b"Crvs"), name: "Curves...", command: "image.adjustments.curves" }, // Shadows/Highlights at its defaults stores no `Fltr` (`hasoptions` false). Known { class: "adaptCorrect", filter_id: 781, name: "Shado&ws/Highlights...", command: "image.adjustments.shadowsHighlights" }, + // Filters without options (measured in Photoshop): `hasoptions` false, no `Fltr`, only the + // four-char `filterID`. The class is that code; see `NO_OPTIONS`. + Known { class: "Blr ", filter_id: code(b"Blr "), name: "Blur", command: "filter.blur.blur" }, + Known { class: "BlrM", filter_id: code(b"BlrM"), name: "Blur More", command: "filter.blur.blurMore" }, + Known { class: "Avrg", filter_id: code(b"Avrg"), name: "Average", command: "filter.blur.average" }, + Known { class: "Dspc", filter_id: code(b"Dspc"), name: "Despeckle", command: "filter.noise.despeckle" }, + Known { class: "FndE", filter_id: code(b"FndE"), name: "Find Edges", command: "filter.stylize.findEdges" }, + Known { class: "Fct ", filter_id: code(b"Fct "), name: "Facet", command: "filter.pixelate.facet" }, + Known { class: "Frgm", filter_id: code(b"Frgm"), name: "Fragment", command: "filter.pixelate.fragment" }, + Known { class: "Shrp", filter_id: code(b"Shrp"), name: "Sharpen", command: "filter.sharpen.sharpen" }, + Known { class: "ShrM", filter_id: code(b"ShrM"), name: "Sharpen More", command: "filter.sharpen.sharpenMore" }, + Known { class: "ShrE", filter_id: code(b"ShrE"), name: "Sharpen Edges", command: "filter.sharpen.sharpenEdges" }, + Known { class: "Slrz", filter_id: code(b"Slrz"), name: "Solarize", command: "filter.stylize.solarize" }, + // Smart Sharpen has a numeric filterID and a stringID class, like Box Blur. + Known { class: SMART_SHARPEN, filter_id: 1131, name: "Smart Sharpen...", command: "filter.sharpen.smartSharpen" }, + Known { class: "DstS", filter_id: code(b"DstS"), name: "Dust & Scratches...", command: "filter.noise.dustAndScratches" }, + Known { class: "LnsF", filter_id: code(b"LnsF"), name: "Lens Flare...", command: "filter.render.lensFlare" }, + // These three also read the item's foreground and background colours (`coloured_params`). + Known { class: "Fbrs", filter_id: code(b"Fbrs"), name: "Fibers...", command: "filter.render.fibers" }, + Known { class: "Clds", filter_id: code(b"Clds"), name: "Clouds", command: "filter.render.clouds" }, + Known { class: "DfrC", filter_id: code(b"DfrC"), name: "Difference Clouds", command: "filter.render.differenceClouds" }, ]; +/// Lens Flare's `Lns ` enum and our `lens` values. Photoshop's codes keep the old lens names: +/// Nikon 35 mm and 105 mm primes, a Panavision movie prime. +const LENSES: [(&[u8; 4], &str); 4] = [(b"Zm ", "zoom"), (b"Nkn ", "prime35"), (b"Nkn1", "prime105"), (b"PnVs", "moviePrime")]; + +/// Classes of the filters that have no options: Photoshop stores them without a `Fltr`. +const NO_OPTIONS: [&str; 11] = ["Blr ", "BlrM", "Avrg", "Dspc", "FndE", "Fct ", "Frgm", "Shrp", "ShrM", "ShrE", "Slrz"]; + +// ---------- Smart Sharpen and Dust & Scratches ---------- +// +// Measured in Photoshop (applied through Action Manager to a smart object, saved, `filterFX` read +// back): +// +// ```text +// Smart Sharpen (filterID 1131): smartSharpen { presetKind = presetKindType enum (only on the +// default entry), useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc, +// blur = blurType enum (GsnB | lensBlur | MtnB), Angl long (Motion Blur only) } +// Dust & Scratches (filterID DstS): DstS { Rds long, Thsh long } +// ``` +// +// PhotoCraft's Smart Sharpen removes a Gaussian blur, non-legacy: Lens Blur, Motion Blur, legacy +// mode, Shadow/Highlight settings or any other key keep the whole entry verbatim. `presetKind` +// only names the dialog's preset: it is accepted on open but not kept (the engine takes no such +// parameter) and not written (Photoshop leaves it out of entries with explicit values). + +const SMART_SHARPEN: &str = "smartSharpen"; +const SMART_SHARPEN_KEYS: [&str; 6] = ["presetKind", "useLegacy", "Amnt", "Rds ", "noiseReduction", "blur"]; + +/// A `UntF` value in `unit`, finite and within `lo..=hi`. +fn unit_in(d: &Descriptor, key: &str, u: &[u8; 4], lo: f64, hi: f64) -> Option { + match d.get(key)? { + Value::UnitFloat { unit, value } if unit == u && value.is_finite() && (lo..=hi).contains(value) => Some(*value), + _ => None, + } +} + +/// `filter.sharpen.smartSharpen` params; `None` for a setting PhotoCraft doesn't render. +fn smart_sharpen_params(f: &Descriptor) -> Option { + if f.items.iter().any(|(k, _)| !SMART_SHARPEN_KEYS.iter().any(|x| k.is(x))) { + return None; + } + let Some(Value::Boolean(false)) = f.get("useLegacy") else { return None }; + let Some(Value::Enumerated { type_id, value }) = f.get("blur") else { return None }; + if !type_id.is("blurType") || !value.is("GsnB") { + return None; + } + let mut out = Map::new(); + out.insert("amount".into(), json!(unit_in(f, "Amnt", b"#Prc", 1.0, 500.0)?)); + out.insert("radius".into(), json!(unit_in(f, "Rds ", b"#Pxl", 0.1, 64.0)?)); + out.insert("reduceNoise".into(), json!(unit_in(f, "noiseReduction", b"#Prc", 0.0, 100.0)?)); + match f.get("presetKind") { + None => {} + Some(Value::Enumerated { type_id, .. }) if type_id.is("presetKindType") => {} + Some(_) => return None, + } + Some(J::Object(out)) +} + fn known_by_command(command: &str) -> Option<&'static Known> { KNOWN.iter().find(|k| k.command == command) } @@ -170,6 +270,31 @@ fn known_by_command(command: &str) -> Option<&'static Known> { /// composite: top-level keys). const CHANNELS: [(&[u8; 4], &str); 4] = [(b"Cmps", ""), (b"Rd ", "red"), (b"Grn ", "green"), (b"Bl ", "blue")]; +/// Mezzotint's `MztT` enum codes and our `type` values (measured in Photoshop by rendering each). +const MEZZOTINT_TYPES: [(&[u8; 4], &str); 10] = [ + (b"FnDt", "fineDots"), + (b"MdmD", "mediumDots"), + (b"GrnD", "grainyDots"), + (b"CrsD", "coarseDots"), + (b"ShrL", "shortLines"), + (b"MdmL", "mediumLines"), + (b"LngL", "longLines"), + (b"ShSt", "shortStrokes"), + (b"MdmS", "mediumStrokes"), + (b"LngS", "longStrokes"), +]; + +/// Color Halftone's `Rds ` (max radius) and `Ang1`..`Ang4` (screen angles) longs, read within +/// the dialog's ranges; anything else is kept verbatim. +fn color_halftone_params(f: &Descriptor) -> Option { + if !only_keys(f, &["Rds ", "Ang1", "Ang2", "Ang3", "Ang4"]) { + return None; + } + let radius = int(f, "Rds ").filter(|r| (4..=127).contains(r))?; + let angle = |k: &str| int(f, k).filter(|a| (-360..=360).contains(a)); + Some(json!({"maxRadius": radius, "channel1": angle("Ang1")?, "channel2": angle("Ang2")?, "channel3": angle("Ang3")?, "channel4": angle("Ang4")?})) +} + fn px(d: &Descriptor, key: &str) -> Option { num(d.get(key)).filter(|v| v.is_finite()) } @@ -181,6 +306,82 @@ fn int(d: &Descriptor, key: &str) -> Option { } } +/// `v` when it lies in `lo..=hi` (the filter dialog's range); out-of-range settings are not +/// opened as editable filters, so a re-save keeps Photoshop's value instead of a clamped one. +fn in_range(v: f64, lo: f64, hi: f64) -> Option { + (lo..=hi).contains(&v).then_some(v) +} + +/// Integer `key` of `f` when it lies in `lo..=hi`. +fn int_in(f: &Descriptor, key: &str, lo: i32, hi: i32) -> Option { + int(f, key).filter(|v| (lo..=hi).contains(v)) +} + +/// True when `f` holds only the given keys, each at most once. A filter with a setting we don't +/// know about (Radial Blur's off-centre centre, for one) or a repeated one is kept verbatim rather +/// than losing that setting. +fn only_keys(f: &Descriptor, keys: &[&str]) -> bool { + f.items.iter().all(|(k, _)| keys.iter().any(|x| k.is(x))) && keys.iter().all(|key| f.items.iter().filter(|(k, _)| k.is(key)).count() <= 1) +} + +/// Our name for enum `key`'s value in `f` (of enum type `type_id`), from `(PSD value, our name)` +/// pairs. An enum of another type is not one we know, so the filter stays verbatim. +fn enum_name(f: &Descriptor, key: &str, type_id: &str, table: &[(&[u8; 4], &'static str)]) -> Option<&'static str> { + let Some(Value::Enumerated { type_id: t, value }) = f.get(key) else { return None }; + if t.as_bytes() != type_id.as_bytes() { + return None; + } + table.iter().find(|(c, _)| value.as_bytes() == &c[..]).map(|(_, n)| *n) +} + +/// True when `f` holds each of the given keys once and nothing else. +fn exact_keys(f: &Descriptor, keys: &[&str]) -> bool { + only_keys(f, keys) && keys.iter().all(|x| f.items.iter().any(|(k, _)| k.is(x))) +} + +/// Finite `key` of `f` stored as a plain double (the type Photoshop writes and we write back). +fn double(f: &Descriptor, key: &str) -> Option { + match f.get(key)? { + Value::Double(v) if v.is_finite() => Some(*v), + _ => None, + } +} + +/// `p[key]` (`default` when absent) when it is a number in `lo..=hi`, the range the filter opens +/// with; anything else is an error, so a saved filter always opens as the same editable filter. +fn pf_range(p: &J, key: &str, default: f64, lo: f64, hi: f64) -> Result { + match p.get(key) { + None | Some(J::Null) => Ok(default), + Some(v) => v.as_f64().filter(|x| (lo..=hi).contains(x)).ok_or_else(|| format!("setting `{key}` ({v}) is not a number in {lo}..={hi}")), + } +} + +/// Integer `p[key]` (rounded; `default` when absent) in `lo..=hi`, as a PSD long. +fn pi_range(p: &J, key: &str, default: f64, lo: i32, hi: i32) -> Result { + let v = pf_range(p, key, default, f64::from(i32::MIN), f64::from(i32::MAX))?.round(); + let range = f64::from(lo)..=f64::from(hi); + if !range.contains(&v) { + return Err(format!("setting `{key}` ({v}) is not a number in {lo}..={hi}")); + } + Ok(Value::Integer(v as i32)) +} + +/// Enum `type_id` holding the PSD value of our name `p[key]` (`default` when absent). +fn enum_param(p: &J, key: &str, default: &str, type_id: &str, table: &[(&[u8; 4], &'static str)]) -> Result { + let name = match p.get(key) { + None | Some(J::Null) => default, + Some(v) => v.as_str().ok_or_else(|| format!("setting `{key}` is not text"))?, + }; + let code = table.iter().find(|(_, n)| *n == name).map(|(c, _)| **c).ok_or_else(|| format!("{key} `{name}` has no PSD equivalent"))?; + Ok(Value::Enumerated { type_id: Id::new(type_id), value: Id::Code(code) }) +} + +/// Smart Blur `SmBQ` / `SmBM` and Radial Blur `BlrM` / `BlrQ` values (measured in Photoshop). +const SMART_BLUR_QUALITY: [(&[u8; 4], &str); 3] = [(b"SBQL", "low"), (b"SBQM", "medium"), (b"SBQH", "high")]; +const SMART_BLUR_MODE: [(&[u8; 4], &str); 3] = [(b"SBMN", "normal"), (b"SBME", "edgeOnly"), (b"SBMO", "overlayEdge")]; +const RADIAL_METHOD: [(&[u8; 4], &str); 2] = [(b"Spn ", "spin"), (b"Zm ", "zoom")]; +const RADIAL_QUALITY: [(&[u8; 4], &str); 3] = [(b"Drft", "draft"), (b"Gd ", "good"), (b"Bst ", "best")]; + fn channel_key(d: &Descriptor) -> Option<&'static str> { let Some(Value::Reference(items)) = d.get("Chnl") else { return None }; let Some(ReferenceItem::Enumerated { value, .. }) = items.first() else { return None }; @@ -258,12 +459,102 @@ fn curves_params(f: &Descriptor) -> Option { Some(J::Object(out)) } +/// Diffuse's `Md ` (`DfsM` enum, measured in Photoshop) ↔ our `mode`. +const DIFFUSE_MODES: [(&str, &str); 4] = [("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("anisotropic", "anisotropic")]; + +/// Trace Contour's `Edg ` (`CntE` enum, measured in Photoshop) ↔ our `edge`. +const TRACE_EDGES: [(&str, &str); 2] = [("Upr ", "upper"), ("Lwr ", "lower")]; + +/// Our name for the enum value of `key` (of type `type_id`) through `table` (Photoshop's value, ours). +fn enum_from(d: &Descriptor, key: &str, type_id: &str, table: &[(&str, &'static str)]) -> Option<&'static str> { + let Some(Value::Enumerated { type_id: t, value }) = d.get(key) else { return None }; + if t.as_bytes() != type_id.as_bytes() { + return None; + } + table.iter().find(|(psd, _)| value.as_bytes() == psd.as_bytes()).map(|(_, ours)| *ours) +} + +// Wind, Extrude and Tiles, measured in Photoshop (each code changes the rendered result). + +/// Wind's `WndM` (enum `WndM`) ↔ our `method`. +const WIND_METHODS: [(&str, &str); 3] = [("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")]; +/// Wind's `Drct` (enum `Drct`) ↔ our `direction`. Photoshop's `Left` streaks extend to the left +/// of an edge, which is our wind from the right. +const WIND_DIRECTIONS: [(&str, &str); 2] = [("Left", "fromRight"), ("Rght", "fromLeft")]; +/// Extrude's `ExtT` (enum `ExtT`) ↔ our `type`. +const EXTRUDE_TYPES: [(&str, &str); 2] = [("Blks", "blocks"), ("Pyrm", "pyramids")]; +/// Extrude's `ExtR` (enum `ExtR`) ↔ our `depthMode`. +const EXTRUDE_DEPTHS: [(&str, &str); 2] = [("Rndm", "random"), ("LvlB", "levelBased")]; +/// Tiles' `FlCl` (enum `FlCl`) ↔ our `fill`. +const TILE_FILLS: [(&str, &str); 4] = [("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")]; +/// Extrude's size and depth (`ExtS`, `ExtD`) and Tiles' count and offset (`TlNm`, `TlOf`), all +/// `long`s, in the engine's (and Photoshop's dialog) ranges. +const EXTRUDE_SIZE: (i32, i32) = (2, 255); +const EXTRUDE_DEPTH: (i32, i32) = (1, 255); +const TILE_COUNT: (i32, i32) = (1, 99); +const TILE_OFFSET: (i32, i32) = (1, 99); + +/// The `bool` at `key`. +fn bool_at(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Boolean(b) => Some(*b), + _ => None, + } +} + +/// Tiles' params; background and foreground fills use the colours the entry carries (`FrgC`, +/// `BckC`). `None` when a setting or a colour that is there can't be read. +fn tiles_params(f: &Descriptor, item: &Descriptor) -> Option { + let mut out = Map::new(); + out.insert("count".into(), json!(int_in(f, "TlNm", TILE_COUNT.0, TILE_COUNT.1)?)); + out.insert("maxOffset".into(), json!(int_in(f, "TlOf", TILE_OFFSET.0, TILE_OFFSET.1)?)); + out.insert("fill".into(), json!(enum_from(f, "FlCl", "FlCl", &TILE_FILLS)?)); + for (psd, ours) in [("FrgC", "foreground"), ("BckC", "background")] { + if item.get(psd).is_some() { + out.insert(ours.into(), get_desc(item, psd).and_then(rgbc_colour)?); + } + } + Some(J::Object(out)) +} + +/// Offset › Undefined Areas: the `FlMd` enum (`Fl `) and our `undefinedAreas`. On a layer +/// Photoshop labels `Bckg` "Set to Transparent"; a smart object is always a layer. +const OFFSET_FILL: [(&str, &str); 3] = [("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")]; + +/// Custom's ranges in Photoshop's dialog: matrix cells, Scale, Offset. +const CUSTOM_CELL: (i32, i32) = (-999, 999); +const CUSTOM_SCALE: (i32, i32) = (1, 9999); +const CUSTOM_OFFSET: (i32, i32) = (-9999, 9999); + +/// `v` when it lies in `lo..=hi`. +fn long_in_range(v: Option, (lo, hi): (i32, i32)) -> Option { + v.filter(|v| (lo..=hi).contains(v)) +} + +/// `filter.other.custom` params for a `Cstm` filter: `Mtrx` is 25 longs, row-major 5 × 5. Values +/// outside Photoshop's ranges are not modelled (the filter stays verbatim). +fn custom_params(f: &Descriptor) -> Option { + if !only_keys(f, &["Mtrx", "Scl ", "Ofst"]) { + return None; + } + let Some(Value::List(m)) = f.get("Mtrx") else { return None }; + if m.len() != 25 { + return None; + } + let kernel: Option> = m.iter().map(|v| long_in_range(int_value(v), CUSTOM_CELL).map(|v| json!(v))).collect(); + let scale = long_in_range(int(f, "Scl "), CUSTOM_SCALE)?; + let offset = long_in_range(int(f, "Ofst"), CUSTOM_OFFSET)?; + Some(json!({"kernel": kernel?, "scale": scale, "offset": offset})) +} + /// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its -/// settings) is not modelled. -fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J)> { +/// settings) is not modelled. `item` is the whole `filterFX` entry (Pointillize reads its `BckC`, +/// Tiles its colours). +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", None) => json!({}), + (c, None) if NO_OPTIONS.contains(&c) => json!({}), (_, None) => return None, ("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")?}), @@ -280,8 +571,67 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) }; json!({"amount": px(f, "Nose")?, "distribution": dist, "monochromatic": mono, "seed": int(f, "FlRs").unwrap_or(0)}) } + ("surfaceBlur", Some(f)) if only_keys(f, &["Rds ", "Thsh"]) => json!({ + "radius": unit_in(f, "Rds ", b"#Pxl", 1.0, 100.0)?, + "threshold": int_in(f, "Thsh", 2, 255)?, + }), + ("SmrB", Some(f)) if only_keys(f, &["Rds ", "Thsh", "SmBQ", "SmBM"]) => json!({ + "radius": in_range(double(f, "Rds ")?, 0.1, 100.0)?, + "threshold": in_range(double(f, "Thsh")?, 0.1, 100.0)?, + "quality": enum_name(f, "SmBQ", "SmBQ", &SMART_BLUR_QUALITY)?, + "mode": enum_name(f, "SmBM", "SmBM", &SMART_BLUR_MODE)?, + }), + ("RdlB", Some(f)) if only_keys(f, &["Amnt", "BlrM", "BlrQ"]) => json!({ + "amount": int_in(f, "Amnt", 1, 100)?, + "method": enum_name(f, "BlrM", "BlrM", &RADIAL_METHOD)?, + "quality": enum_name(f, "BlrQ", "BlrQ", &RADIAL_QUALITY)?, + }), ("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)?, ("Embs", Some(f)) => json!({"angle": int(f, "Angl")?, "height": int(f, "Hght")?, "amount": int(f, "Amnt")?}), + (SMART_SHARPEN, Some(f)) => smart_sharpen_params(f)?, + ("DstS", Some(f)) => { + if f.items.iter().any(|(k, _)| !k.is("Rds ") && !k.is("Thsh")) { + return None; + } + json!({"radius": int_in(f, "Rds ", 1, 500)?, "threshold": int_in(f, "Thsh", 0, 255)?}) + } + ("Dfs ", Some(f)) => json!({"mode": enum_from(f, "Md ", "DfsM", &DIFFUSE_MODES)?}), + ("TrcC", Some(f)) => json!({"level": int_in(f, "Lvl ", 0, 255)?, "edge": enum_from(f, "Edg ", "CntE", &TRACE_EDGES)?}), + ("Crst", Some(f)) => json!({"cellSize": int_in(f, "ClSz", CELL_SIZE.0, CELL_SIZE.1)?}), + ("Pntl", Some(f)) => { + let mut p = json!({"cellSize": int_in(f, "ClSz", CELL_SIZE.0, CELL_SIZE.1)?}); + // Pointillize paints the gaps in the background colour the entry carries. + if item.get("BckC").is_some() { + p["background"] = get_desc(item, "BckC").and_then(rgbc_colour)?; + } + p + } + ("Wnd ", Some(f)) => json!({ + "method": enum_from(f, "WndM", "WndM", &WIND_METHODS)?, + "direction": enum_from(f, "Drct", "Drct", &WIND_DIRECTIONS)?, + }), + ("Extr", Some(f)) => json!({ + "type": enum_from(f, "ExtT", "ExtT", &EXTRUDE_TYPES)?, + "size": int_in(f, "ExtS", EXTRUDE_SIZE.0, EXTRUDE_SIZE.1)?, + "depth": int_in(f, "ExtD", EXTRUDE_DEPTH.0, EXTRUDE_DEPTH.1)?, + "depthMode": enum_from(f, "ExtR", "ExtR", &EXTRUDE_DEPTHS)?, + "solidFrontFaces": bool_at(f, "ExtF")?, + "maskIncompleteBlocks": bool_at(f, "ExtM")?, + }), + ("Tls ", Some(f)) => tiles_params(f, item)?, + ("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)?}) + } + ("Cstm", Some(f)) => custom_params(f)?, + // Only the settings and value types Photoshop writes (and we write back); anything else + // keeps the filter verbatim. + ("LnsF", Some(f)) if exact_keys(f, &["Brgh", "FlrC", "Lns "]) => { + let centre = get_desc(f, "FlrC").filter(|c| c.class_id.is("Pnt ") && exact_keys(c, &["Hrzn", "Vrtc"]))?; + let lens = enum_name(f, "Lns ", "Lns ", &LENSES)?; + json!({"brightness": int(f, "Brgh")?, "centerX": double(centre, "Hrzn")?, "centerY": double(centre, "Vrtc")?, "lens": lens}) + } ("Lvls", Some(f)) => levels_params(f)?, ("Crvs", Some(f)) => curves_params(f)?, _ => return None, @@ -322,6 +672,38 @@ fn rgbc_colour(d: &Descriptor) -> Option { Some(json!([c("Rd ")?, c("Grn ")?, c("Bl ")?, 1.0])) } +/// Fibers, Clouds and Difference Clouds draw with the item's foreground and background colours: +/// their params are the `Fltr` settings plus those colours. +fn coloured_params(class: &str, item: &Descriptor, f: &Descriptor) -> Option<(&'static str, J)> { + let k = KNOWN.iter().find(|k| k.class == class)?; + // An `RGBC` of three `doub`s in 0–255, as Photoshop writes it (and we write it back); any + // other colour keeps the filter verbatim. + let colour = |key: &str| { + get_desc(item, key) + .filter(|c| c.class_id.is("RGBC") && exact_keys(c, &["Rd ", "Grn ", "Bl "])) + .filter(|c| ["Rd ", "Grn ", "Bl "].iter().all(|k| double(c, k).is_some_and(|v| (0.0..=255.0).contains(&v)))) + .and_then(rgbc_colour) + }; + // Only the settings Photoshop writes; an extra or repeated one keeps the filter verbatim. + let keys: &[&str] = if class == "Fbrs" { &["Vrnc", "Strg", "RndS"] } else { &["FlRs"] }; + if !exact_keys(f, keys) { + return None; + } + // The seeds (`RndS`, `FlRs`) are signed longs; ours is their bit pattern. + let mut p = match class { + "Fbrs" => json!({"variance": int(f, "Vrnc")?, "strength": int(f, "Strg")?, "seed": int(f, "RndS")? as u32}), + _ => json!({"seed": int(f, "FlRs")? as u32}), + }; + let o = p.as_object_mut()?; + o.insert("foreground".into(), colour("FrgC")?); + o.insert("background".into(), colour("BckC")?); + Some((k.command, p)) +} + +fn seed_param(p: &J) -> Value { + Value::Integer(p.get("seed").and_then(J::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0) as i32) +} + /// `filter.filterGallery` params for a `GEfc` filter item; `None` when an effect or setting is /// not modelled. fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { @@ -469,8 +851,14 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)), + Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), - c => c.and_then(|c| known_params(c, fltr)), + Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)), + // A `Fltr` that is there but not a descriptor is settings we can't read: kept verbatim + // rather than taken for a filter stored without options (which would drop it on save). + _ if fltr.is_none() && item.get("Fltr").is_some() => None, + c => c.and_then(|c| known_params(c, fltr, item)), }; let (command, params) = match known { Some((c, p)) => (c.to_string(), p), @@ -520,6 +908,15 @@ fn pi(p: &J, key: &str, default: f64) -> Value { Value::Integer(pf(p, key, default).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32) } +/// A whole-number setting as a long, clamped to `range` like the engine clamps it; a fraction +/// has no PSD equivalent (Photoshop stores these as integers). +fn whole(v: f64, (lo, hi): (i32, i32), what: &str) -> Result { + if v.fract() != 0.0 { + return Err(format!("{what} ({v}) is not a whole number")); + } + Ok(Value::Integer(v.clamp(f64::from(lo), f64::from(hi)) as i32)) +} + fn channel_ref(code: &[u8; 4]) -> Value { Value::Reference(vec![ReferenceItem::Enumerated { class: Class { name: UnicodeString::new_nul(""), class_id: Id::new("Chnl") }, @@ -574,6 +971,29 @@ fn adjustments(p: &J, curves: bool) -> Result { Ok(Value::List(list)) } +/// Crystallize's and Pointillize's cell size range (`ClSz`, a `long`). +const CELL_SIZE: (i32, i32) = (3, 300); + +/// Our `key` (a string) as Photoshop's enum value through `table` (Photoshop's value, ours). +fn enum_out(p: &J, key: &str, default: &str, type_id: &str, table: &[(&str, &str)]) -> Result { + let ours = match p.get(key) { + None => default, + Some(v) => v.as_str().ok_or_else(|| format!("`{key}` is not a string"))?, + }; + let (psd, _) = table.iter().find(|(_, o)| *o == ours).ok_or_else(|| format!("{key} `{ours}` has no PSD equivalent"))?; + Ok(enumv(type_id, psd)) +} + +/// `key` rounded to a `long` in `lo..=hi`. +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) +} + +/// Param `key` as a `bool` (false when absent or not a bool, as the engine reads it). +fn bool_param(p: &J, key: &str) -> Value { + Value::Boolean(p.get(key).and_then(J::as_bool).unwrap_or(false)) +} + /// The `Fltr` descriptor of a modelled filter (`None` = written without options). fn fltr_for(k: &Known, p: &J) -> Result, String> { let d = Descriptor::new(k.class); @@ -593,8 +1013,102 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { .with("Mnch", Value::Boolean(p.get("monochromatic").and_then(J::as_bool).unwrap_or(false))) .with("FlRs", pi(p, "seed", 0.0)) } + "surfaceBlur" => d.with("Rds ", unit(b"#Pxl", pf_range(p, "radius", 5.0, 1.0, 100.0)?)).with("Thsh", pi_range(p, "threshold", 15.0, 2, 255)?), + "SmrB" => d + .with("Rds ", Value::Double(pf_range(p, "radius", 3.0, 0.1, 100.0)?)) + .with("Thsh", Value::Double(pf_range(p, "threshold", 25.0, 0.1, 100.0)?)) + .with("SmBQ", enum_param(p, "quality", "high", "SmBQ", &SMART_BLUR_QUALITY)?) + .with("SmBM", enum_param(p, "mode", "normal", "SmBM", &SMART_BLUR_MODE)?), + "RdlB" => { + // Photoshop wrote no centre key for a centred blur; an off-centre one is unmeasured. + for k in ["centerX", "centerY"] { + if (pf_range(p, k, 0.5, 0.0, 1.0)? - 0.5).abs() > 1e-9 { + return Err("an off-centre Radial Blur centre is not written to PSD".into()); + } + } + d.with("Amnt", pi_range(p, "amount", 10.0, 1, 100)?) + .with("BlrM", enum_param(p, "method", "spin", "BlrM", &RADIAL_METHOD)?) + .with("BlrQ", enum_param(p, "quality", "good", "BlrQ", &RADIAL_QUALITY)?) + } "Msc " => d.with("ClSz", unit(b"#Pxl", pf(p, "cellSize", 10.0))), + // Photoshop's Mezzotint has no seed (it is random on every render), so `seed` is not saved. + "Mztn" => { + let t = p.get("type").and_then(J::as_str).unwrap_or("fineDots"); + let code = MEZZOTINT_TYPES.iter().find(|(_, k)| *k == t).map_or(b"FnDt", |(c, _)| *c); + d.with("MztT", Value::Enumerated { type_id: Id::new("MztT"), value: Id::Code(*code) }) + } + "ClrH" => { + // Longs within the dialog's ranges (what the engine accepts: radius 4..127, angles ±360). + let long = |key: &str, default: f64, lo: f64, hi: f64| Value::Integer(pf(p, key, default).round().clamp(lo, hi) as i32); + d.with("Rds ", long("maxRadius", 8.0, 4.0, 127.0)) + .with("Ang1", long("channel1", 108.0, -360.0, 360.0)) + .with("Ang2", long("channel2", 162.0, -360.0, 360.0)) + .with("Ang3", long("channel3", 90.0, -360.0, 360.0)) + .with("Ang4", long("channel4", 45.0, -360.0, 360.0)) + } "Embs" => d.with("Angl", pi(p, "angle", 135.0)).with("Hght", pi(p, "height", 3.0)).with("Amnt", pi(p, "amount", 100.0)), + SMART_SHARPEN => { + // The engine's defaults and ranges (it clamps the same way when rendering). + d.with("useLegacy", Value::Boolean(false)) + .with("Amnt", unit(b"#Prc", pf(p, "amount", 100.0).clamp(1.0, 500.0))) + .with("Rds ", unit(b"#Pxl", pf(p, "radius", 1.0).clamp(0.1, 64.0))) + .with("noiseReduction", unit(b"#Prc", pf(p, "reduceNoise", 10.0).clamp(0.0, 100.0))) + .with("blur", enumv("blurType", "GsnB")) + } + // Photoshop stores whole pixels and levels. + "DstS" => { + let whole = |key: &str, default: f64, lo: f64, hi: f64| Value::Integer(pf(p, key, default).round().clamp(lo, hi) as i32); + d.with("Rds ", whole("radius", 1.0, 1.0, 500.0)).with("Thsh", whole("threshold", 0.0, 0.0, 255.0)) + } + // Photoshop stores no random seed for these: `seed` is ours only and is not written. + "Dfs " => d.with("Md ", enum_out(p, "mode", "normal", "DfsM", &DIFFUSE_MODES)?), + "TrcC" => d.with("Lvl ", pi_in(p, "level", 128.0, 0, 255)).with("Edg ", enum_out(p, "edge", "lower", "CntE", &TRACE_EDGES)?), + "Crst" => d.with("ClSz", pi_in(p, "cellSize", 10.0, CELL_SIZE.0, CELL_SIZE.1)), + "Pntl" => d.with("ClSz", pi_in(p, "cellSize", 5.0, CELL_SIZE.0, CELL_SIZE.1)), + // Photoshop stores no random seed for these: our `seed` is not written. + "Wnd " => { + d.with("WndM", enum_out(p, "method", "wind", "WndM", &WIND_METHODS)?).with("Drct", enum_out(p, "direction", "fromRight", "Drct", &WIND_DIRECTIONS)?) + } + "Extr" => d + .with("ExtT", enum_out(p, "type", "blocks", "ExtT", &EXTRUDE_TYPES)?) + .with("ExtS", pi_in(p, "size", 30.0, EXTRUDE_SIZE.0, EXTRUDE_SIZE.1)) + .with("ExtD", pi_in(p, "depth", 30.0, EXTRUDE_DEPTH.0, EXTRUDE_DEPTH.1)) + .with("ExtR", enum_out(p, "depthMode", "random", "ExtR", &EXTRUDE_DEPTHS)?) + .with("ExtF", bool_param(p, "solidFrontFaces")) + .with("ExtM", bool_param(p, "maskIncompleteBlocks")), + "Tls " => d + .with("TlNm", pi_in(p, "count", 10.0, TILE_COUNT.0, TILE_COUNT.1)) + .with("TlOf", pi_in(p, "maxOffset", 10.0, TILE_OFFSET.0, TILE_OFFSET.1)) + .with("FlCl", enum_out(p, "fill", "background", "FlCl", &TILE_FILLS)?), + "Ofst" => { + // As the engine reads it: anything but repeat / transparent wraps. + let ours = p.get("undefinedAreas").and_then(J::as_str).unwrap_or("wrap"); + let fill = OFFSET_FILL.iter().find(|(_, o)| *o == ours).map_or("Wrp ", |(psd, _)| *psd); + d.with("Hrzn", pi(p, "horizontal", 0.0)).with("Vrtc", pi(p, "vertical", 0.0)).with("Fl ", enumv("FlMd", fill)) + } + "Cstm" => { + // The engine's identity kernel when none is given; missing cells are 0. + let kernel = p.get("kernel").and_then(J::as_array); + let cell = |i: usize| match kernel { + Some(a) => a.get(i).and_then(J::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0), + None => f64::from(u8::from(i == 12)), + }; + let m = (0..25).map(|i| whole(cell(i), CUSTOM_CELL, "a kernel value")).collect::, _>>()?; + d.with("Scl ", whole(pf(p, "scale", 1.0), CUSTOM_SCALE, "Scale")?) + .with("Ofst", whole(pf(p, "offset", 0.0), CUSTOM_OFFSET, "Offset")?) + .with("Mtrx", Value::List(m)) + } + "LnsF" => { + let lens = p.get("lens").and_then(J::as_str).unwrap_or("zoom"); + let code = LENSES.iter().find(|(_, l)| *l == lens).ok_or_else(|| format!("lens `{lens}` has no PSD equivalent"))?.0; + let mut centre = Descriptor::new("Pnt ").with("Hrzn", Value::Double(pf(p, "centerX", 0.5))).with("Vrtc", Value::Double(pf(p, "centerY", 0.5))); + centre.name = UnicodeString::new_nul(""); + d.with("Brgh", pi(p, "brightness", 100.0)) + .with("FlrC", Value::Descriptor(centre)) + .with("Lns ", Value::Enumerated { type_id: Id::new("Lns "), value: Id::Code(*code) }) + } + "Fbrs" => d.with("Vrnc", pi(p, "variance", 16.0)).with("Strg", pi(p, "strength", 4.0)).with("RndS", seed_param(p)), + "Clds" | "DfrC" => d.with("FlRs", seed_param(p)), "Lvls" => d.with("Adjs", adjustments(p, false)?), "Crvs" => d.with("Adjs", adjustments(p, true)?), "adaptCorrect" => { @@ -603,6 +1117,9 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { } return Ok(None); } + // No settings to write; any parameter keys an old record carries have no effect on the + // filter either, so nothing is lost. + c if NO_OPTIONS.contains(&c) => return Ok(None), _ => return Err("not a recognized filter".into()), })) } @@ -624,6 +1141,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if f.command == GALLERY_COMMAND { return gallery_item(f); } + if f.command == hue_sat::COMMAND { + return hue_sat::export_item(f); + } + if perspective_warp::handles(&f.command) { + return perspective_warp::export_item(f); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; @@ -633,19 +1156,42 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { } let k = known_by_command(&f.command).ok_or("this filter has no PSD equivalent")?; let fltr = fltr_for(k, &f.params)?; + // Pointillize's background, Tiles' colours and the render filters' colours are the entry's; + // the others store Photoshop's defaults. + let (fg, bg) = match k.class { + "Pntl" => (rgbc(0.0), colour_rgbc(f.params.get("background"), 255.0)?), + "Tls " | "Fbrs" | "Clds" | "DfrC" => (colour_rgbc(f.params.get("foreground"), 0.0)?, colour_rgbc(f.params.get("background"), 255.0)?), + _ => (rgbc(0.0), rgbc(255.0)), + }; + // The render filters are written with the descriptor names Photoshop gives them: the filter's + // own name on `Fltr` and empty ones on the colours. + let render = matches!(k.class, "Fbrs" | "Clds" | "DfrC" | "LnsF"); + let (fltr, fg, bg) = if render { (fltr.map(|f| named(f, k.name.trim_end_matches("..."))), named_value(fg), named_value(bg)) } else { (fltr, fg, bg) }; let mut d = Descriptor::new("filterFX") .with("Nm ", Value::Text(UnicodeString::new_nul(k.name))) .with("blendOptions", blend_options(f)) .with("enab", Value::Boolean(f.visible)) .with("hasoptions", Value::Boolean(fltr.is_some())) - .with("FrgC", rgbc(0.0)) - .with("BckC", rgbc(255.0)); + .with("FrgC", fg) + .with("BckC", bg); if let Some(x) = fltr { d = d.with("Fltr", Value::Descriptor(x)); } Ok(d.with("filterID", Value::Integer(k.filter_id))) } +fn named(mut d: Descriptor, name: &str) -> Descriptor { + d.name = UnicodeString::new_nul(name); + d +} + +fn named_value(v: Value) -> Value { + match v { + Value::Descriptor(d) => Value::Descriptor(named(d, "")), + other => other, + } +} + /// Builds a `filterFX` object; filters that can't be written are left out, with a message each. pub fn filter_fx(stack: &FilterStack, layer: &str, warnings: &mut Vec) -> Descriptor { let mut list = Vec::new(); @@ -672,6 +1218,16 @@ fn set(d: &mut Descriptor, key: &str, v: Value) { } } +/// Replaces item `key` in place, or inserts it before item `before` (appends it without one). +fn set_before(d: &mut Descriptor, key: &str, v: Value, before: &str) { + if let Some(e) = d.items.iter_mut().find(|(k, _)| k.is(key)) { + e.1 = v; + return; + } + let at = d.items.iter().position(|(k, _)| k.is(before)).unwrap_or(d.items.len()); + d.items.insert(at, (Id::new(key), v)); +} + /// Everything needed to write the placed-layer blocks of a smart object. #[derive(Clone, Debug)] pub struct PlacedSpec<'a> { @@ -691,16 +1247,17 @@ pub struct PlacedSpec<'a> { } /// Where the frame corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs. -/// The frame is [`crate::blocks::placed_frame`] for an affine placement (the inverse of -/// `parse_smart`), the source rect for a projective one (as `parse_smart_perspective` reads it). -fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { +/// The frame is [`crate::blocks::placed_frame`] of `warp`, the warp the block stores, for an +/// affine placement (the inverse of `parse_smart`), the source rect for a projective one (as +/// `parse_smart_perspective` reads it). +fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [f64; 8] { let (w, h) = s.size; let [a, b, c, d, e, f] = s.transform.m; let map = |x: f64, y: f64| match &s.perspective { Some(p) => photocraft_algo::transform::Homography(*p).apply(x, y), None => (a * x + c * y + e, b * x + d * y + f), }; - let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(s.warp, w, h) }; + let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(warp, w, h) }; let mut out = [0.0; 8]; for (i, (x, y)) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().enumerate() { (out[2 * i], out[2 * i + 1]) = map(x, y); @@ -709,15 +1266,15 @@ fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { } /// The transform quad: the source corners (top-left, top-right, bottom-right, bottom-left). -fn quad(s: &PlacedSpec<'_>) -> Value { - Value::List(quad_points(s).into_iter().map(Value::Double).collect()) +fn quad(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> Value { + Value::List(quad_points(s, warp).into_iter().map(Value::Double).collect()) } /// Has the placement moved from the quad a template descriptor stores (`Trnf`)? -fn quad_moved(template: &Descriptor, s: &PlacedSpec<'_>) -> bool { +fn quad_moved(template: &Descriptor, s: &PlacedSpec<'_>, warp: Option<&Warp>) -> bool { let Some(Value::List(pts)) = template.get("Trnf") else { return true }; let old: Vec = pts.iter().filter_map(|v| crate::blocks::num(Some(v))).collect(); - let new = quad_points(s); + let new = quad_points(s, warp); old.len() != 8 || old.iter().zip(new).any(|(a, b)| (a - b).abs() > 1e-6 * (1.0 + a.abs())) } @@ -725,27 +1282,33 @@ fn enumv(t: &str, v: &str) -> Value { Value::Enumerated { type_id: Id::new(t), value: Id::new(v) } } -/// The `warp` descriptor (also the `PlLd` warp). Multi-patch meshes have no 1 × 1 envelope and -/// are written as no warp, with a message. -fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> Value { - let none = Warp::none([0.0, 0.0, size.0, size.1]); - let mut w = w.unwrap_or(&none).clone(); - let mesh = match (&w.style, &w.mesh) { - (WarpStyle::Custom, Some(m)) if m.points.len() == 16 => Some(m.clone()), - (WarpStyle::Custom, _) => { - warnings.push("a custom warp with more than one patch was written to PSD without its warp".into()); - w = none.clone(); - None - } - _ => None, - }; +/// The mesh of a split warp: a custom warp of more than one patch, which `SoLd` stores as +/// `quiltWarp` and `PlLd` can't store. +fn split_mesh(w: Option<&Warp>) -> Option<&BezierMesh> { + w.filter(|w| w.style == WarpStyle::Custom).and_then(|w| w.mesh.as_ref()).filter(|m| m.points.len() != 16 && m.is_valid()) +} + +/// The warp `SoLd` stores: none, with a message, for a split warp of more patches along a side +/// than a `quiltWarp` is read with, so its corners are framed by the source rect as on reopen. +fn sold_warp<'a>(w: Option<&'a Warp>, warnings: &mut Vec) -> Option<&'a Warp> { + let too_big = split_mesh(w).is_some_and(|m| m.us.len().max(m.vs.len()) > crate::blocks::QUILT_MAX_PATCHES + 1); + if too_big { + warnings.push(format!("a split warp of more than {} patches along a side was written to PSD without its warp", crate::blocks::QUILT_MAX_PATCHES)); + return None; + } + w +} + +/// Style, bend, distortions, axis, bounds and order: the keys a `warp` and a `quiltWarp` +/// descriptor share, in Photoshop's order. +fn warp_head(class: &str, w: &Warp) -> Descriptor { let [x0, y0, x1, y1] = w.bounds; let bounds = Descriptor::new("classFloatRect") .with("Top ", Value::Double(y0)) .with("Left", Value::Double(x0)) .with("Btom", Value::Double(y1)) .with("Rght", Value::Double(x1)); - let mut d = Descriptor::new("warp") + Descriptor::new(class) .with("warpStyle", enumv("warpStyle", w.style.psd_name())) .with("warpValue", Value::Double(w.bend)) .with("warpPerspective", Value::Double(w.h_distort)) @@ -753,19 +1316,74 @@ fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> .with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" })) .with("bounds", Value::Descriptor(bounds)) .with("uOrder", Value::Integer(4)) - .with("vOrder", Value::Integer(4)); + .with("vOrder", Value::Integer(4)) +} + +/// The control points of a custom warp: an object array of their x and y. +fn mesh_points(m: &BezierMesh) -> Value { + let body = Descriptor::new("rationalPoint") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[1]).collect() }); + Value::ObjectArray(ObjectArray { prefix: m.points.len() as u32, body }) +} + +/// The `warp` descriptor (also the `PlLd` warp). A split warp is written as no warp: `SoLd` +/// keeps it in `quiltWarp` ([`quilt_desc`]), next to this `warpNone`, as Photoshop does. A +/// custom warp without a usable mesh is written as no warp, with a message. +fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> Value { + let none = Warp::none([0.0, 0.0, size.0, size.1]); + let mut w = w.unwrap_or(&none).clone(); + let mesh = match (&w.style, &w.mesh) { + (WarpStyle::Custom, Some(m)) if m.points.len() == 16 => Some(m.clone()), + (WarpStyle::Custom, Some(m)) if m.is_valid() => { + w = none.clone(); + None + } + (WarpStyle::Custom, _) => { + warnings.push("a custom warp without a valid mesh was written to PSD without its warp".into()); + w = none.clone(); + None + } + _ => None, + }; + let mut d = warp_head("warp", &w); if let Some(m) = mesh { - let body = Descriptor::new("rationalPoint") - .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[0]).collect() }) - .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: m.points.iter().map(|p| p[1]).collect() }); - d = d.with( - "customEnvelopeWarp", - Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body }))), - ); + d = d.with("customEnvelopeWarp", Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", mesh_points(&m)))); } Value::Descriptor(d) } +/// The `quiltWarp` descriptor of a split warp, in Photoshop's layout: `deformNumCols` × +/// `deformNumRows` control points, and the patch boundaries (`quiltSliceX` / `quiltSliceY`) at +/// the knots across the warp's box. The imported `quiltWarp` (`old`) gives its own slices where +/// they still bound the mesh's patches, so their units and padding stay Photoshop's. +fn quilt_desc(w: &Warp, mesh: &BezierMesh, old: Option<&Descriptor>) -> Value { + let old_env = old.and_then(|q| get_desc(q, "customEnvelopeWarp")); + let [x0, y0, x1, y1] = w.bounds; + let slices = |key: &str, knots: &[f64], lo: f64, hi: f64| { + if let Some(env) = old_env + && crate::blocks::quilt_knots(env, key, knots.len() - 1).as_deref() == Some(knots) + && let Some(v) = env.get(key) + { + return v.clone(); + } + let (lo, hi) = if (hi - lo).is_finite() && hi > lo { (lo, hi) } else { (0.0, 1.0) }; + let values: Vec = knots.iter().map(|u| lo + u * (hi - lo)).collect(); + let prefix = values.len() as u32; + Value::ObjectArray(ObjectArray { prefix, body: Descriptor::new("UntF").with(key, Value::UnitFloats { unit: *b"#Pxl", values }) }) + }; + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", &mesh.us, x0, x1)) + .with("quiltSliceY", slices("quiltSliceY", &mesh.vs, y0, y1)) + .with("meshPoints", mesh_points(mesh)); + Value::Descriptor( + warp_head("quiltWarp", w) + .with("deformNumRows", Value::Integer(mesh.ny() as i32)) + .with("deformNumCols", Value::Integer(mesh.nx() as i32)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)), + ) +} + fn ratio(n: i32, d: i32) -> Value { Value::Descriptor(Descriptor::new("null").with("numerator", Value::Integer(n)).with("denominator", Value::Integer(d))) } @@ -779,20 +1397,36 @@ fn pad4(mut v: Vec) -> Vec { /// `SoLd` block data. With `template` (the imported descriptor) every key it has is kept and only /// the placed id (for a duplicate), transform (when it moved), warp and filter stack are rewritten; -/// otherwise a fresh descriptor in Photoshop's key order. +/// otherwise a fresh descriptor in Photoshop's key order. A split warp goes in `quiltWarp`. pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { - let warp = warp_desc(s.warp, s.size, warnings); + let stored = sold_warp(s.warp, warnings); + let warp = warp_desc(stored, s.size, warnings); + let split = stored.zip(split_mesh(stored)); let d = match template { Some(t) => { let mut d = t.clone(); if text_of(t, "placed").as_deref() != Some(s.placed) { set(&mut d, "placed", Value::Text(UnicodeString::new_nul(s.placed))); } - if quad_moved(t, s) { - set(&mut d, "Trnf", quad(s)); - set(&mut d, "nonAffineTransform", quad(s)); + if quad_moved(t, s, stored) { + set(&mut d, "Trnf", quad(s, stored)); + set(&mut d, "nonAffineTransform", quad(s, stored)); } set(&mut d, "warp", warp); + // `quiltWarp` is read before `warp`, so the imported one stays only while it still + // says what the layer's warp is (unchanged, or unreadable while the layer has none); + // otherwise it is rewritten, or dropped for a warp that isn't split. + let old = get_desc(t, "quiltWarp"); + let kept = old.is_some_and(|q| match crate::blocks::warp_of(q) { + Some(w) => w.as_ref() == stored, + None => s.warp.is_none(), + }); + if !kept { + match split { + Some((w, m)) => set_before(&mut d, "quiltWarp", quilt_desc(w, m, old), "warp"), + None => d.items.retain(|(k, _)| !k.is("quiltWarp")), + } + } match &s.filter_fx { Some(fx) => set(&mut d, "filterFX", Value::Descriptor(fx.clone())), None => d.items.retain(|(k, _)| !k.is("filterFX")), @@ -811,8 +1445,12 @@ pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: & .with("frameCount", Value::Integer(1)) .with("Annt", Value::Integer(16)) .with("Type", Value::Integer(2)) - .with("Trnf", quad(s)) - .with("nonAffineTransform", quad(s)) + .with("Trnf", quad(s, stored)) + .with("nonAffineTransform", quad(s, stored)); + if let Some((w, m)) = split { + d = d.with("quiltWarp", quilt_desc(w, m, None)); + } + let mut d = d .with("warp", warp) .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(s.size.0)).with("Hght", Value::Double(s.size.1)))) .with("Rslt", unit(b"#Rsl", s.dpi)); @@ -830,8 +1468,9 @@ pub fn sold_bytes(template: Option<&Descriptor>, s: &PlacedSpec<'_>, warnings: & } /// `PlLd` block data (`plcL` version 3): the legacy placed-layer record Photoshop writes next to -/// `SoLd`. +/// `SoLd`. It has no split warp: it stores none, its corners framed by the source rect. pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { + let warp = s.warp.filter(|_| split_mesh(s.warp).is_none()); let mut out = b"plcL".to_vec(); out.extend_from_slice(&3u32.to_be_bytes()); let id = s.idnt.as_bytes(); @@ -841,7 +1480,7 @@ pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { for v in [1i32, 1, 16, 2] { out.extend_from_slice(&v.to_be_bytes()); } - if let Value::List(q) = quad(s) { + if let Value::List(q) = quad(s, warp) { for v in q { if let Value::Double(x) = v { out.extend_from_slice(&x.to_be_bytes()); @@ -849,7 +1488,7 @@ pub fn plld_bytes(s: &PlacedSpec<'_>, warnings: &mut Vec) -> Vec { } } out.extend_from_slice(&0u32.to_be_bytes()); - let Value::Descriptor(w) = warp_desc(s.warp, s.size, warnings) else { return pad4(out) }; + let Value::Descriptor(w) = warp_desc(warp, s.size, warnings) else { return pad4(out) }; out.extend(VersionedDescriptor::new(w).to_bytes()); pad4(out) } @@ -1055,6 +1694,13 @@ mod tests { sf("filter.blur.gaussianBlur", json!({"radius": 4.0})), sf("filter.blur.boxBlur", json!({"radius": 5.0})), sf("filter.blur.motionBlur", json!({"angle": -20, "distance": 10.0})), + sf("filter.blur.surfaceBlur", json!({"radius": 12.0, "threshold": 40})), + sf("filter.blur.smartBlur", json!({"radius": 5.0, "threshold": 25.0, "quality": "high", "mode": "normal"})), + sf("filter.blur.smartBlur", json!({"radius": 3.0, "threshold": 30.0, "quality": "medium", "mode": "overlayEdge"})), + sf("filter.blur.smartBlur", json!({"radius": 0.5, "threshold": 0.1, "quality": "low", "mode": "edgeOnly"})), + sf("filter.blur.radialBlur", json!({"amount": 10, "method": "spin", "quality": "draft"})), + sf("filter.blur.radialBlur", json!({"amount": 20, "method": "zoom", "quality": "good"})), + sf("filter.blur.radialBlur", json!({"amount": 100, "method": "spin", "quality": "best"})), sf("filter.sharpen.unsharpMask", json!({"amount": 150.0, "radius": 2.0, "threshold": 0})), sf("filter.noise.addNoise", json!({"amount": 12.0, "distribution": "uniform", "monochromatic": false, "seed": 1234})), sf("filter.noise.median", json!({"radius": 3.0})), @@ -1062,14 +1708,33 @@ mod tests { sf("filter.other.maximum", json!({"radius": 2.0})), sf("filter.other.minimum", json!({"radius": 2.0})), sf("filter.pixelate.mosaic", json!({"cellSize": 8.0})), + sf("filter.pixelate.mezzotint", json!({"type": "mediumStrokes"})), + sf("filter.pixelate.colorHalftone", json!({"maxRadius": 6, "channel1": 10, "channel2": 20, "channel3": 30, "channel4": 40})), sf("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})), + sf("filter.sharpen.smartSharpen", json!({"amount": 120.0, "radius": 2.5, "reduceNoise": 5.0})), + sf("filter.noise.dustAndScratches", json!({"radius": 4, "threshold": 20})), + sf("filter.stylize.diffuse", json!({"mode": "normal"})), + sf("filter.stylize.diffuse", json!({"mode": "darkenOnly"})), + sf("filter.stylize.diffuse", json!({"mode": "lightenOnly"})), + sf("filter.stylize.diffuse", json!({"mode": "anisotropic"})), + sf("filter.stylize.traceContour", json!({"level": 60, "edge": "lower"})), + sf("filter.stylize.traceContour", json!({"level": 128, "edge": "upper"})), + sf("filter.stylize.traceContour", json!({"level": 0, "edge": "upper"})), + sf("filter.stylize.traceContour", json!({"level": 255, "edge": "lower"})), + sf("filter.pixelate.crystallize", json!({"cellSize": 10})), + sf("filter.pixelate.crystallize", json!({"cellSize": 3})), + sf("filter.pixelate.crystallize", json!({"cellSize": 300})), + sf("filter.pixelate.pointillize", json!({"cellSize": 7, "background": [1.0, 1.0, 1.0, 1.0]})), + sf("filter.pixelate.pointillize", json!({"cellSize": 300, "background": [0.0, 0.5, 1.0, 1.0]})), + sf("filter.other.offset", json!({"horizontal": 12, "vertical": -7, "undefinedAreas": "transparent"})), + sf("filter.other.custom", json!({"kernel": SHARPEN, "scale": 1, "offset": 0})), sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), sf("image.adjustments.shadowsHighlights", json!({})), // A pencil-drawn curve: 256 output levels (`Mpng`). sf("image.adjustments.curves", json!({"pencil": true, "points": (0..256).rev().collect::>()})), ]; - for mut f in filters { + for mut f in filters.into_iter().chain(wind_extrude_tiles_settings()) { f.blend = BlendMode::Overlay; f.opacity = 0.75; f.visible = false; @@ -1080,6 +1745,841 @@ mod tests { } } + /// The filters without options, their measured `filterID`s (as Photoshop wrote them). + const MEASURED_NO_OPTIONS: [(&str, i32); 11] = [ + ("filter.blur.blur", 1114403360), + ("filter.blur.blurMore", 1114403405), + ("filter.blur.average", 1098281575), + ("filter.noise.despeckle", 1148416099), + ("filter.stylize.findEdges", 1181639749), + ("filter.pixelate.facet", 1180922912), + ("filter.pixelate.fragment", 1181902701), + ("filter.sharpen.sharpen", 1399353968), + ("filter.sharpen.sharpenMore", 1399353933), + ("filter.sharpen.sharpenEdges", 1399353925), + ("filter.stylize.solarize", 1399616122), + ]; + + /// A filter without options as Photoshop saves it: `hasoptions` false, no `Fltr`, a localized + /// name and the current colours. + fn photoshop_no_options_item(name: &str, filter_id: Value) -> Descriptor { + let rgb = |r: f64, g: f64, b: f64| { + Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(r)).with("Grn ", Value::Double(g)).with("Bl ", Value::Double(b))) + }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 60.0)).with("Md ", enumv("BlnM", "Mltp")))) + .with("enab", Value::Boolean(false)) + .with("hasoptions", Value::Boolean(false)) + .with("FrgC", rgb(167.0, 157.0, 147.0)) + .with("BckC", rgb(255.0, 255.0, 255.0)) + .with("filterID", filter_id) + } + + #[test] + fn filters_without_options_round_trip_in_the_measured_shape() { + let keys = ["Nm ", "blendOptions", "enab", "hasoptions", "FrgC", "BckC", "filterID"]; + for (command, id) in MEASURED_NO_OPTIONS { + let mut f = sf(command, json!({})); + f.blend = BlendMode::Overlay; + f.opacity = 0.75; + f.visible = false; + let d = item_for_filter(&f).unwrap(); + let got: Vec = d.items.iter().map(|(k, _)| String::from_utf8_lossy(k.as_bytes()).into_owned()).collect(); + assert_eq!(got, keys, "{command}"); + assert_eq!(d.get("hasoptions"), Some(&Value::Boolean(false)), "{command}"); + assert_eq!(d.get("filterID"), Some(&Value::Integer(id)), "{command}"); + let back = filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()); + assert_eq!(back, f, "{command}"); + // Keys an old record may carry have no effect on these filters: still written. + let extra = item_for_filter(&sf(command, json!({"radius": 3.0, "layer": "L"}))).unwrap(); + assert_eq!(extra.get("filterID"), Some(&Value::Integer(id)), "{command}"); + for p in [json!(null), json!("x"), json!([1, 2])] { + assert!(item_for_filter(&sf(command, p)).is_ok(), "{command}"); + } + } + } + + #[test] + fn photoshop_filters_without_options_open_editable() { + for (command, id) in MEASURED_NO_OPTIONS { + let item = photoshop_no_options_item("Weichzeichnen", Value::Integer(id)); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, command); + assert_eq!(f.params, json!({})); + assert_eq!((f.visible, f.blend), (false, BlendMode::Multiply)); + assert!((f.opacity - 0.6).abs() < 1e-6); + } + } + + #[test] + fn malformed_filters_without_options_are_kept_verbatim() { + let blur = code(b"Blr "); + let cases = [ + // An id no filter has, a missing id, ids of the wrong type. + photoshop_no_options_item("X", Value::Integer(code(b"Zzzz"))), + Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)), + photoshop_no_options_item("X", Value::Double(f64::from(blur))), + photoshop_no_options_item("X", Value::Double(f64::NAN)), + photoshop_no_options_item("X", Value::Text(UnicodeString::new_nul("Blr "))), + photoshop_no_options_item("X", Value::Integer(-1)), + // Settings we don't know of on a filter that has none. + photoshop_no_options_item("X", Value::Integer(blur)).with("Fltr", Value::Descriptor(Descriptor::new("Blr ").with("Amnt", Value::Integer(5)))), + // Settings that aren't a descriptor, on a filter that has none. + photoshop_no_options_item("X", Value::Integer(blur)).with("Fltr", Value::Integer(5)), + photoshop_no_options_item("X", Value::Integer(blur)).with("Fltr", Value::Text(UnicodeString::new_nul("Blr "))), + photoshop_no_options_item("X", Value::Integer(781)).with("Fltr", Value::Integer(5)), + ]; + for item in cases { + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + // Written back (a bare item gains blending and visibility) and still not editable. + let written = item_for_filter(&f).unwrap(); + assert_eq!(written.get("Fltr"), item.get("Fltr"), "{item:?}"); + let back = filter_from_item(&written); + assert_eq!((back.command.as_str(), &back.params["filterID"]), (UNSUPPORTED_FILTER, &f.params["filterID"])); + } + } + + /// Smart Sharpen's `Fltr` in the measured shape (no `presetKind`, no `Angl`). + fn smart_sharpen(amount: f64, radius: f64, noise: f64, blur: &str) -> Descriptor { + Descriptor::new("smartSharpen") + .with("useLegacy", Value::Boolean(false)) + .with("Amnt", unit(b"#Prc", amount)) + .with("Rds ", unit(b"#Pxl", radius)) + .with("noiseReduction", unit(b"#Prc", noise)) + .with("blur", enumv("blurType", blur)) + } + + fn dust(radius: i32, threshold: i32) -> Descriptor { + Descriptor::new("DstS").with("Rds ", Value::Integer(radius)).with("Thsh", Value::Integer(threshold)) + } + + /// The `Fltr` descriptor PhotoCraft writes for `f`, after a byte round trip. + fn written_fltr(f: &SmartFilter) -> Descriptor { + let d = Descriptor::from_bytes(&item_for_filter(f).unwrap().to_bytes()).unwrap(); + get_desc(&d, "Fltr").unwrap().clone() + } + + /// Measured in Photoshop: Smart Sharpen 120 %, 2.5 px, noise 5 %, Gaussian Blur + /// (filterID 1131); the default entry adds `presetKind` first, which is not kept. + #[test] + fn smart_sharpen_photoshop_entry_opens_and_writes_back() { + let fltr = smart_sharpen(120.0, 2.5, 5.0, "GsnB"); + let item = ps_item("Smart Sharpen...", fltr.clone(), 1131); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, "filter.sharpen.smartSharpen"); + assert_eq!(f.params, json!({"amount": 120.0, "radius": 2.5, "reduceNoise": 5.0})); + assert_eq!(written_fltr(&f), fltr); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1131)); + assert_eq!(get_desc(&d, "Fltr").unwrap().class_id, Id::Str(b"smartSharpen".to_vec())); + // The four-character keys and enum values are codes, as Photoshop writes them. + let bytes = d.to_bytes(); + assert_eq!(count(&bytes, b"\0\0\0\0blurenum\0\0\0\x08blurType\0\0\0\0GsnB"), 1); + assert_eq!(count(&bytes, b"\0\0\0\0Rds UntF#Pxl"), 1); + + // With `presetKind` (any value): opens, but the params hold only the engine's own keys + // (it rejects unknown ones), and a re-save leaves it out as Photoshop does for explicit + // values. + for kind in ["presetKindDefault", "presetKindCustom", "Xxxx"] { + let mut with_preset = Descriptor::new("smartSharpen").with("presetKind", enumv("presetKindType", kind)); + with_preset.items.extend(smart_sharpen(200.0, 1.0, 10.0, "GsnB").items); + let f = filter_from_item(&Descriptor::from_bytes(&ps_item("Smart Sharpen...", with_preset, 1131).to_bytes()).unwrap()); + assert_eq!(f.command, "filter.sharpen.smartSharpen"); + let keys: Vec<&str> = f.params.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys.len(), 3, "{keys:?}"); + assert!(keys.iter().all(|k| ["amount", "radius", "reduceNoise"].contains(k)), "{keys:?}"); + assert_eq!(f.params, json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0})); + assert_eq!(written_fltr(&f), smart_sharpen(200.0, 1.0, 10.0, "GsnB")); + } + + // Without options PhotoCraft writes the engine's defaults, Gaussian Blur. + assert_eq!(written_fltr(&sf("filter.sharpen.smartSharpen", json!({}))), smart_sharpen(100.0, 1.0, 10.0, "GsnB")); + } + + /// Lens Blur (Photoshop's default), Motion Blur and legacy mode render differently from + /// PhotoCraft's Smart Sharpen, so those entries stay verbatim. + #[test] + fn smart_sharpen_settings_we_dont_render_stay_verbatim() { + let mut default_entry = Descriptor::new("smartSharpen").with("presetKind", enumv("presetKindType", "presetKindDefault")); + default_entry.items.extend(smart_sharpen(200.0, 1.0, 10.0, "lensBlur").items); + let motion = smart_sharpen(80.0, 1.5, 0.0, "MtnB").with("Angl", Value::Integer(30)); + let mut legacy = smart_sharpen(100.0, 1.0, 10.0, "GsnB"); + set(&mut legacy, "useLegacy", Value::Boolean(true)); + for fltr in [default_entry, motion, legacy] { + let item = ps_item("Smart Sharpen...", fltr, 1131); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(f.params["filterID"], 1131); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn malformed_smart_sharpen_is_kept_verbatim() { + let g = || smart_sharpen(100.0, 1.0, 10.0, "GsnB"); + let without = |key: &str| { + let mut d = g(); + d.items.retain(|(k, _)| !k.is(key)); + d + }; + let with = |key: &str, v: Value| { + let mut d = g(); + set(&mut d, key, v); + d + }; + let cases = vec![ + without("useLegacy"), + without("Amnt"), + without("Rds "), + without("noiseReduction"), + without("blur"), + with("Amnt", Value::Double(100.0)), + with("Amnt", unit(b"#Pxl", 100.0)), + with("Rds ", Value::Integer(1)), + with("useLegacy", Value::Integer(0)), + with("Amnt", unit(b"#Prc", 0.0)), + with("Amnt", unit(b"#Prc", 501.0)), + with("Rds ", unit(b"#Pxl", 0.05)), + with("Rds ", unit(b"#Pxl", 65.0)), + with("Rds ", unit(b"#Pxl", f64::NAN)), + with("noiseReduction", unit(b"#Prc", -1.0)), + with("noiseReduction", unit(b"#Prc", f64::INFINITY)), + with("blur", enumv("blurType", "Xxxx")), + with("blur", enumv("BlrM", "GsnB")), + with("blur", Value::Integer(0)), + with("presetKind", Value::Integer(1)), + g().with("Angl", Value::Integer(30)), + g().with("shadowMode", Value::Descriptor(Descriptor::new("adaptCorrectTones"))), + ]; + for fltr in cases { + let item = ps_item("Smart Sharpen...", fltr, 1131); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // No `Fltr` at all (hasoptions false). + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(1131)); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + /// Measured in Photoshop: Dust & Scratches at its defaults (1, 0) and at 4, 20 (filterID + /// `DstS`, 1148417107; both settings `long`). + #[test] + fn dust_and_scratches_photoshop_entry_opens_and_writes_back() { + for (radius, threshold) in [(1, 0), (4, 20), (500, 255)] { + let fltr = dust(radius, threshold); + let item = ps_item("Dust & Scratches...", fltr.clone(), 1_148_417_107); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, "filter.noise.dustAndScratches"); + assert_eq!(f.params, json!({"radius": radius, "threshold": threshold})); + assert_eq!(written_fltr(&f), fltr); + assert_eq!(int(&item_for_filter(&f).unwrap(), "filterID"), Some(1_148_417_107)); + } + // No `Fltr` (hasoptions false): kept verbatim. + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"DstS"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn malformed_dust_and_scratches_is_kept_verbatim() { + let cases = vec![ + Descriptor::new("DstS").with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", Value::Integer(1)), + Descriptor::new("DstS").with("Rds ", Value::Double(1.0)).with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", unit(b"#Pxl", 1.0)).with("Thsh", Value::Integer(0)), + Descriptor::new("DstS").with("Rds ", Value::Integer(1)).with("Thsh", Value::Boolean(false)), + dust(0, 0), + dust(501, 0), + dust(1, -1), + dust(1, 256), + dust(i32::MIN, i32::MAX), + dust(1, 0).with("Xtra", Value::Integer(1)), + ]; + for fltr in cases { + let item = ps_item("Dust & Scratches...", fltr, code(b"DstS")); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + /// Every option's range ends round trip; hostile params are written in range (the engine + /// clamps the same way), never a panic. + #[test] + fn smart_sharpen_and_dust_option_values_round_trip() { + let mut cases = Vec::new(); + for amount in [1.0, 100.0, 500.0] { + for radius in [0.1, 1.0, 64.0] { + for noise in [0.0, 10.0, 100.0] { + cases.push(sf("filter.sharpen.smartSharpen", json!({"amount": amount, "radius": radius, "reduceNoise": noise}))); + } + } + } + for radius in [1, 2, 100, 500] { + for threshold in [0, 1, 128, 255] { + cases.push(sf("filter.noise.dustAndScratches", json!({"radius": radius, "threshold": threshold}))); + } + } + for f in cases { + let back = filter_from_item(&Descriptor::from_bytes(&item_for_filter(&f).unwrap().to_bytes()).unwrap()); + assert_eq!(back, f); + } + let defaults = json!({"amount": 100.0, "radius": 1.0, "reduceNoise": 10.0}); + for (command, p, want) in [ + ( + "filter.sharpen.smartSharpen", + json!({"amount": 1e9, "radius": -5.0, "reduceNoise": "x"}), + json!({"amount": 500.0, "radius": 0.1, "reduceNoise": 10.0}), + ), + ("filter.sharpen.smartSharpen", json!({"presetKind": "presetKindDefault", "amount": null}), defaults.clone()), + ("filter.sharpen.smartSharpen", json!(null), defaults.clone()), + ("filter.noise.dustAndScratches", json!({"radius": 2.4, "threshold": 1e12}), json!({"radius": 2, "threshold": 255})), + ("filter.noise.dustAndScratches", json!({"radius": -3, "threshold": -3}), json!({"radius": 1, "threshold": 0})), + ("filter.noise.dustAndScratches", json!([]), json!({"radius": 1, "threshold": 0})), + ] { + let back = filter_from_item(&item_for_filter(&sf(command, p.clone())).unwrap()); + assert_eq!((back.command.as_str(), &back.params), (command, &want), "{p}"); + } + } + + /// Every option value of Wind, Extrude and Tiles (and the ends of their ranges). + fn wind_extrude_tiles_settings() -> Vec { + let mut v = Vec::new(); + for method in ["wind", "blast", "stagger"] { + for direction in ["fromRight", "fromLeft"] { + v.push(sf("filter.stylize.wind", json!({"method": method, "direction": direction}))); + } + } + for t in ["blocks", "pyramids"] { + for mode in ["random", "levelBased"] { + for (solid, mask) in [(false, false), (true, false), (false, true), (true, true)] { + v.push(sf( + "filter.stylize.extrude", + json!({"type": t, "size": 7, "depth": 9, "depthMode": mode, "solidFrontFaces": solid, "maskIncompleteBlocks": mask}), + )); + } + } + } + v.push(sf( + "filter.stylize.extrude", + json!({"type": "blocks", "size": 2, "depth": 1, "depthMode": "random", "solidFrontFaces": false, "maskIncompleteBlocks": false}), + )); + v.push(sf( + "filter.stylize.extrude", + json!({"type": "pyramids", "size": 255, "depth": 255, "depthMode": "levelBased", "solidFrontFaces": true, "maskIncompleteBlocks": true}), + )); + for fill in ["background", "foreground", "inverse", "unaltered"] { + v.push(sf( + "filter.stylize.tiles", + json!({"count": 6, "maxOffset": 20, "fill": fill, "foreground": [1.0, 0.0, 0.5, 1.0], "background": [0.0, 0.5, 1.0, 1.0]}), + )); + } + for (count, offset) in [(1, 1), (99, 99)] { + v.push(sf( + "filter.stylize.tiles", + json!({"count": count, "maxOffset": offset, "fill": "background", "foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + )); + } + v + } + + /// A `filterFX` entry shaped as Photoshop saves Wind, Extrude and Tiles (measured in Photoshop: + /// applied through Action Manager to a smart object, saved, `filterFX` read back): the item's + /// `FrgC`/`BckC` are the document colours, `Fltr` holds the settings, `filterID` the class code. + fn measured_item(fltr: Descriptor, fg: f64, bg: f64) -> Descriptor { + let id = code(fltr.class_id.as_bytes().try_into().unwrap()); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter..."))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(fg)) + .with("BckC", rgbc(bg)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(id)) + } + + fn wind(method: &str, direction: &str) -> Descriptor { + Descriptor::new("Wnd ").with("WndM", enumv("WndM", method)).with("Drct", enumv("Drct", direction)) + } + + fn extrude(t: &str, size: Value, depth: Value, mode: &str, solid: Value, mask: Value) -> Descriptor { + Descriptor::new("Extr") + .with("ExtT", enumv("ExtT", t)) + .with("ExtS", size) + .with("ExtD", depth) + .with("ExtR", enumv("ExtR", mode)) + .with("ExtF", solid) + .with("ExtM", mask) + } + + fn tiles(count: Value, offset: Value, fill: &str) -> Descriptor { + Descriptor::new("Tls ").with("TlNm", count).with("TlOf", offset).with("FlCl", enumv("FlCl", fill)) + } + + #[test] + fn wind_extrude_tiles_open_from_photoshop_shapes() { + let (long, yes, no) = (Value::Integer, Value::Boolean(true), Value::Boolean(false)); + let cases = [ + // The measured files. + (wind("Blst", "Left"), "filter.stylize.wind", json!({"method": "blast", "direction": "fromRight"})), + (wind("Stgr", "Rght"), "filter.stylize.wind", json!({"method": "stagger", "direction": "fromLeft"})), + (wind("Wnd ", "Rght"), "filter.stylize.wind", json!({"method": "wind", "direction": "fromLeft"})), + ( + extrude("Blks", long(7), long(9), "Rndm", yes.clone(), no.clone()), + "filter.stylize.extrude", + json!({"type": "blocks", "size": 7, "depth": 9, "depthMode": "random", "solidFrontFaces": true, "maskIncompleteBlocks": false}), + ), + ( + extrude("Pyrm", long(12), long(20), "LvlB", no, yes), + "filter.stylize.extrude", + json!({"type": "pyramids", "size": 12, "depth": 20, "depthMode": "levelBased", "solidFrontFaces": false, "maskIncompleteBlocks": true}), + ), + ( + tiles(long(6), long(20), "FlFr"), + "filter.stylize.tiles", + json!({"count": 6, "maxOffset": 20, "fill": "foreground", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(4), long(5), "FlIn"), + "filter.stylize.tiles", + json!({"count": 4, "maxOffset": 5, "fill": "inverse", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(10), long(10), "FlBc"), + "filter.stylize.tiles", + json!({"count": 10, "maxOffset": 10, "fill": "background", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ( + tiles(long(99), long(1), "FlSm"), + "filter.stylize.tiles", + json!({"count": 99, "maxOffset": 1, "fill": "unaltered", "foreground": [0.2, 0.2, 0.2, 1.0], "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ]; + for (fltr, command, params) in cases { + let item = measured_item(fltr, 51.0, 255.0); + // Through bytes, as read from a file. + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back in the measured shape: same class, keys and their order, value types + // (long, bool, enum with its type), filterID; Tiles also its colours. + let back = item_for_filter(&f).unwrap(); + assert_eq!(get_desc(&back, "Fltr").map(Descriptor::to_bytes), get_desc(&item, "Fltr").map(Descriptor::to_bytes), "{command}"); + assert_eq!(back.get("filterID"), item.get("filterID"), "{command}"); + if command == "filter.stylize.tiles" { + assert_eq!((back.get("FrgC"), back.get("BckC")), (item.get("FrgC"), item.get("BckC"))); + } + } + // The class codes: numeric filterIDs of the four-char classes. + for (c, id) in [("filter.stylize.wind", 1466852384), ("filter.stylize.extrude", 1165522034), ("filter.stylize.tiles", 1416393504)] { + assert_eq!(item_for_filter(&sf(c, json!({}))).unwrap().get("filterID"), Some(&Value::Integer(id))); + } + // A filter found by its filterID alone (no `Fltr` class to go by) still needs its settings. + let bare = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(1466852384)); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + // Only Tiles reads the entry's colours; Wind and Extrude write the defaults. + let f = filter_from_item(&measured_item(wind("Wnd ", "Left"), 51.0, 0.0)); + assert_eq!(f.params, json!({"method": "wind", "direction": "fromRight"})); + let back = item_for_filter(&f).unwrap(); + assert_eq!((back.get("FrgC"), back.get("BckC")), (Some(&rgbc(0.0)), Some(&rgbc(255.0)))); + // Tiles without colours in its params (or the entry) writes the engine's black and white. + let f = filter_from_item(&Descriptor::new("filterFX").with("Fltr", Value::Descriptor(tiles(long(4), long(5), "FlBc")))); + assert_eq!(f.params, json!({"count": 4, "maxOffset": 5, "fill": "background"})); + let back = item_for_filter(&f).unwrap(); + assert_eq!((back.get("FrgC"), back.get("BckC")), (Some(&rgbc(0.0)), Some(&rgbc(255.0)))); + } + + #[test] + fn wind_extrude_tiles_write_defaults_and_photoshop_ranges() { + let fltr = |c: &str, p: J| match item_for_filter(&sf(c, p)).unwrap().get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("no Fltr: {other:?}"), + }; + let long = Value::Integer; + let (yes, no) = (Value::Boolean(true), Value::Boolean(false)); + // Defaults are the engine's; our random seed has no PSD key and is not written. + assert_eq!(fltr("filter.stylize.wind", json!({"seed": 9})), wind("Wnd ", "Left")); + assert_eq!(fltr("filter.stylize.extrude", json!({"seed": 9})), extrude("Blks", long(30), long(30), "Rndm", no.clone(), no.clone())); + assert_eq!(fltr("filter.stylize.tiles", json!({"seed": 9})), tiles(long(10), long(10), "FlBc")); + // Out of range, fractional, non-numeric and non-bool values as the engine reads them. + assert_eq!( + fltr("filter.stylize.extrude", json!({"size": 1e12, "depth": -5, "solidFrontFaces": "yes", "maskIncompleteBlocks": true})), + extrude("Blks", long(255), long(1), "Rndm", no.clone(), yes) + ); + assert_eq!(fltr("filter.stylize.tiles", json!({"count": 0, "maxOffset": 12.6})), tiles(long(1), long(13), "FlBc")); + assert_eq!(fltr("filter.stylize.tiles", json!({"count": "many", "maxOffset": 1000})), tiles(long(10), long(99), "FlBc")); + assert_eq!(fltr("filter.stylize.extrude", json!({"size": f64::NAN})), extrude("Blks", long(30), long(30), "Rndm", no.clone(), no)); + } + + #[test] + fn malformed_wind_extrude_tiles_are_kept_verbatim() { + let long = Value::Integer; + let (yes, no) = (Value::Boolean(true), Value::Boolean(false)); + let cases = [ + // Missing keys. + Descriptor::new("Wnd "), + Descriptor::new("Wnd ").with("WndM", enumv("WndM", "Blst")), + Descriptor::new("Wnd ").with("Drct", enumv("Drct", "Left")), + Descriptor::new("Extr").with("ExtT", enumv("ExtT", "Blks")).with("ExtS", long(7)).with("ExtD", long(9)).with("ExtR", enumv("ExtR", "Rndm")), + Descriptor::new("Tls ").with("TlNm", long(6)).with("TlOf", long(20)), + Descriptor::new("Tls ").with("TlNm", long(6)).with("FlCl", enumv("FlCl", "FlFr")), + // Unknown enum values and enum types (Photoshop falls back to a default for these; + // we keep them as they are rather than guess). + wind("Wild", "Left"), + wind("Blst", "Up "), + Descriptor::new("Wnd ").with("WndM", enumv("Drct", "Blst")).with("Drct", enumv("Drct", "Left")), + extrude("Cube", long(7), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(9), "Sine", yes.clone(), no.clone()), + tiles(long(6), long(20), "FlXx"), + Descriptor::new("Tls ").with("TlNm", long(6)).with("TlOf", long(20)).with("FlCl", enumv("BlnM", "FlFr")), + // Wrong types (also NaN). + Descriptor::new("Wnd ").with("WndM", long(1)).with("Drct", enumv("Drct", "Left")), + extrude("Blks", Value::Double(7.0), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), unit(b"#Pxl", 9.0), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(9), "Rndm", long(1), no.clone()), + extrude("Blks", long(7), long(9), "Rndm", yes.clone(), Value::Double(f64::NAN)), + tiles(Value::Double(f64::NAN), long(20), "FlFr"), + tiles(long(6), Value::Text(UnicodeString::new_nul("20")), "FlFr"), + // Out of range. + extrude("Blks", long(1), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(256), long(9), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(0), "Rndm", yes.clone(), no.clone()), + extrude("Blks", long(7), long(i32::MAX), "Rndm", yes, no), + tiles(long(0), long(20), "FlFr"), + tiles(long(100), long(20), "FlFr"), + tiles(long(6), long(0), "FlFr"), + tiles(long(6), long(i32::MIN), "FlFr"), + ]; + for fltr in cases { + let item = measured_item(fltr, 0.0, 255.0); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // A Tiles entry whose colour is unreadable is kept verbatim too. + for (key, bad) in [ + ("FrgC", Value::Integer(3)), + ("BckC", Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))), + ( + "BckC", + Value::Descriptor( + Descriptor::new("RGBC").with("Rd ", Value::Double(f64::NAN)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)), + ), + ), + ] { + let mut item = measured_item(tiles(long(6), long(20), "FlFr"), 0.0, 255.0); + set(&mut item, key, bad); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + // Values with no PSD equivalent are reported, not guessed. + for (c, p) in [ + ("filter.stylize.wind", json!({"method": "gale"})), + ("filter.stylize.wind", json!({"direction": "fromTop"})), + ("filter.stylize.wind", json!({"direction": 1})), + ("filter.stylize.extrude", json!({"type": "cubes"})), + ("filter.stylize.extrude", json!({"depthMode": null})), + ("filter.stylize.tiles", json!({"fill": "transparent"})), + ("filter.stylize.tiles", json!({"foreground": "teal"})), + ("filter.stylize.tiles", json!({"background": [1.0]})), + ("filter.stylize.tiles", json!({"background": [f64::NAN, 0.0, 0.0]})), + ] { + assert!(item_for_filter(&sf(c, p.clone())).is_err(), "{c} {p}"); + } + } + + /// Photoshop's default Custom kernel: a 3 × 3 sharpen centred in the 5 × 5. + const SHARPEN: [i32; 25] = [0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, -1, 5, -1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0]; + + /// A `filterFXList` item as measured in Photoshop (applied through Action Manager to a smart + /// object, saved, `filterFX` read back). + fn measured_named_item(id: &[u8; 4], name: &str, fltr: Descriptor) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(id))) + } + + fn offset_fltr(h: Value, v: Value, fill: Value) -> Descriptor { + Descriptor::new("Ofst").with("Hrzn", h).with("Vrtc", v).with("Fl ", fill) + } + + fn custom_fltr(scale: Value, offset: Value, m: Vec) -> Descriptor { + Descriptor::new("Cstm").with("Scl ", scale).with("Ofst", offset).with("Mtrx", Value::List(m)) + } + + fn longs(m: &[i32]) -> Vec { + m.iter().map(|v| Value::Integer(*v)).collect() + } + + #[test] + fn offset_round_trips_every_fill_mode() { + for (fill, ours) in [("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")] { + for (h, v) in [(0, 0), (12, -7), (5, 3), (i32::MIN, i32::MAX)] { + let f = sf("filter.other.offset", json!({"horizontal": h, "vertical": v, "undefinedAreas": ours})); + let d = item_for_filter(&f).unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert_eq!(*fltr, offset_fltr(Value::Integer(h), Value::Integer(v), enumv("FlMd", fill))); + assert_eq!(int(&d, "filterID"), Some(1_332_114_292)); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + } + } + + /// The measured descriptors (Wrap 0/0, Set to Background 12/−7, Repeat 5/3) open as Offset. + #[test] + fn offset_measured_descriptors_open_as_offset() { + for (h, v, fill, ours) in [(0, 0, "Wrp ", "wrap"), (12, -7, "Bckg", "transparent"), (5, 3, "Rpt ", "repeat")] { + let item = measured_named_item(b"Ofst", "Offset...", offset_fltr(Value::Integer(h), Value::Integer(v), enumv("FlMd", fill))); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.other.offset"); + assert_eq!(f.params, json!({"horizontal": h, "vertical": v, "undefinedAreas": ours})); + // Written back exactly as measured. + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn offset_written_with_engine_defaults() { + // No params: 0, 0, Wrap Around (the engine's defaults); fractional pixels round. + let d = item_for_filter(&sf("filter.other.offset", json!({}))).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), offset_fltr(Value::Integer(0), Value::Integer(0), enumv("FlMd", "Wrp "))); + let d = item_for_filter(&sf("filter.other.offset", json!({"horizontal": 2.6, "vertical": 3e12, "undefinedAreas": "bogus"}))).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), offset_fltr(Value::Integer(3), Value::Integer(i32::MAX), enumv("FlMd", "Wrp "))); + } + + #[test] + fn malformed_offset_is_kept_verbatim() { + let i = Value::Integer; + for fltr in [ + offset_fltr(i(1), i(2), enumv("FlMd", "Xxxx")), + offset_fltr(i(1), i(2), Value::Integer(1)), + offset_fltr(Value::Double(1.5), i(2), enumv("FlMd", "Wrp ")), + offset_fltr(i(1), unit(b"#Pxl", f64::NAN), enumv("FlMd", "Wrp ")), + Descriptor::new("Ofst").with("Hrzn", i(1)).with("Fl ", enumv("FlMd", "Wrp ")), + Descriptor::new("Ofst"), + // A mistyped enum, a setting we don't write back and a repeated one. + offset_fltr(i(1), i(2), enumv("Xxxx", "Wrp ")), + offset_fltr(i(1), i(2), enumv("FlMd", "Wrp ")).with("Xtra", i(1)), + offset_fltr(i(1), i(2), enumv("FlMd", "Wrp ")).with("Hrzn", i(3)), + ] { + let item = measured_named_item(b"Ofst", "Offset...", fltr); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "kept byte for byte"); + } + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"Ofst"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn custom_round_trips() { + let edge: Vec = (0..25) + .map(|i| { + if i == 12 { + 8 + } else if (6..=18).contains(&i) && i % 5 != 0 && i % 5 != 4 { + -1 + } else { + 0 + } + }) + .collect(); + let full: Vec = (0..25).map(|i| i * 83 - 999).collect(); + for (kernel, scale, offset) in [(SHARPEN.to_vec(), 1, 0), (edge, 2, 10), (full, 9999, -9999), (vec![999; 25], 1, 9999)] { + let f = sf("filter.other.custom", json!({"kernel": kernel, "scale": scale, "offset": offset})); + let d = item_for_filter(&f).unwrap(); + assert_eq!(*get_desc(&d, "Fltr").unwrap(), custom_fltr(Value::Integer(scale), Value::Integer(offset), longs(&kernel))); + assert_eq!(int(&d, "filterID"), Some(1_131_639_917)); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + } + + /// The measured descriptors (the default sharpen; a blur at Scale 2, Offset 10) open as Custom. + #[test] + fn custom_measured_descriptors_open_as_custom() { + let blur = [0, 0, 0, 0, 0, 0, 1, 2, 1, 0, 0, 2, 4, 2, 0, 0, 1, 2, 1, 0, 0, 0, 0, 0, 0]; + for (kernel, scale, offset) in [(SHARPEN, 1, 0), (blur, 2, 10)] { + let item = measured_named_item(b"Cstm", "Custom...", custom_fltr(Value::Integer(scale), Value::Integer(offset), longs(&kernel))); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.other.custom"); + assert_eq!(f.params, json!({"kernel": kernel, "scale": scale, "offset": offset})); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + + #[test] + fn custom_written_like_the_engine_reads_it() { + let fltr = |p: J| get_desc(&item_for_filter(&sf("filter.other.custom", p)).unwrap(), "Fltr").cloned().unwrap(); + // No kernel: the engine's identity; short kernels pad with 0; out-of-range values clamp. + let mut id = [0; 25]; + id[12] = 1; + assert_eq!(fltr(json!({})), custom_fltr(Value::Integer(1), Value::Integer(0), longs(&id))); + let mut short = [0; 25]; + short[0] = 3; + short[1] = -999; + assert_eq!(fltr(json!({"kernel": [3, -5000], "scale": 0, "offset": 20000})), custom_fltr(Value::Integer(1), Value::Integer(9999), longs(&short))); + // Fractions have no PSD equivalent. + for p in [json!({"kernel": [0.5]}), json!({"scale": 1.5}), json!({"offset": -0.25})] { + assert!(item_for_filter(&sf("filter.other.custom", p.clone())).is_err(), "{p}"); + } + } + + #[test] + fn malformed_custom_is_kept_verbatim() { + let i = Value::Integer; + let mut wrong_type = longs(&SHARPEN); + wrong_type[3] = Value::Double(1.0); + for fltr in [ + custom_fltr(i(1), i(0), longs(&SHARPEN[..24])), + custom_fltr(i(1), i(0), longs(&[0; 26])), + custom_fltr(i(1), i(0), wrong_type), + custom_fltr(i(1), i(0), longs(&[1000; 25])), + custom_fltr(i(0), i(0), longs(&SHARPEN)), + custom_fltr(i(10_000), i(0), longs(&SHARPEN)), + custom_fltr(i(1), i(-10_000), longs(&SHARPEN)), + custom_fltr(Value::Double(f64::NAN), i(0), longs(&SHARPEN)), + custom_fltr(i(1), unit(b"#Pxl", 1.0), longs(&SHARPEN)), + Descriptor::new("Cstm").with("Scl ", i(1)).with("Ofst", i(0)).with("Mtrx", i(5)), + Descriptor::new("Cstm").with("Scl ", i(1)).with("Mtrx", Value::List(longs(&SHARPEN))), + Descriptor::new("Cstm"), + // A setting we don't write back. + custom_fltr(i(1), i(0), longs(&SHARPEN)).with("Xtra", i(1)), + ] { + let item = measured_named_item(b"Cstm", "Custom...", fltr); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "kept byte for byte"); + } + } + + /// A `filterFX` item holding `fltr`, as Photoshop writes a filter with options. + fn pixelate_item(name: &str, fltr: Descriptor, filter_id: i32) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "Nrml")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(filter_id)) + } + + #[test] + fn mezzotint_round_trips_every_type_and_reads_photoshops_shape() { + for (code, ty) in MEZZOTINT_TYPES { + let f = sf("filter.pixelate.mezzotint", json!({"type": ty})); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1_299_870_830)); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("Mztn")); + assert_eq!(enum_of(fltr, "MztT"), Some(&code[..])); + assert!(matches!(fltr.get("MztT"), Some(Value::Enumerated { type_id, .. }) if type_id.is("MztT"))); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f, "{ty}"); + } + // Measured in Photoshop: `{'_class': 'Mztn', 'MztT': 'MztT.CrsD'}` and `LngS`, filterID 1299870830. + for (code, ty) in [("CrsD", "coarseDots"), ("LngS", "longStrokes")] { + let item = pixelate_item("Mezzotint...", Descriptor::new("Mztn").with("MztT", enumv("MztT", code)), 1_299_870_830); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), ("filter.pixelate.mezzotint", &json!({"type": ty}))); + } + // No type or an unknown type is Fine Dots, like the engine; the seed + // (PhotoCraft only) is not saved. + for p in [json!({}), json!({"type": "sparkles"}), json!({"type": "fineDots", "seed": 77})] { + let d = item_for_filter(&sf("filter.pixelate.mezzotint", p)).unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert_eq!(enum_of(fltr, "MztT"), Some(&b"FnDt"[..])); + assert_eq!(fltr.items.len(), 1); + } + } + + #[test] + fn color_halftone_round_trips_and_reads_photoshops_shape() { + for p in [ + json!({"maxRadius": 8, "channel1": 108, "channel2": 162, "channel3": 90, "channel4": 45}), + json!({"maxRadius": 4, "channel1": -360, "channel2": 0, "channel3": 360, "channel4": -45}), + json!({"maxRadius": 127, "channel1": 1, "channel2": 2, "channel3": 3, "channel4": 4}), + ] { + let f = sf("filter.pixelate.colorHalftone", p); + let d = item_for_filter(&f).unwrap(); + assert_eq!(int(&d, "filterID"), Some(1_131_180_616)); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("ClrH")); + for k in ["Rds ", "Ang1", "Ang2", "Ang3", "Ang4"] { + assert!(matches!(fltr.get(k), Some(Value::Integer(_))), "{k} is a long"); + } + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + } + // Measured in Photoshop: `{'_class': 'ClrH', 'Rds ': 6, 'Ang1': 10, 'Ang2': 20, 'Ang3': 30, 'Ang4': 40}`. + let measured = Descriptor::new("ClrH") + .with("Rds ", Value::Integer(6)) + .with("Ang1", Value::Integer(10)) + .with("Ang2", Value::Integer(20)) + .with("Ang3", Value::Integer(30)) + .with("Ang4", Value::Integer(40)); + let item = pixelate_item("Color Halftone...", measured.clone(), 1_131_180_616); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.pixelate.colorHalftone"); + assert_eq!(f.params, json!({"maxRadius": 6, "channel1": 10, "channel2": 20, "channel3": 30, "channel4": 40})); + assert_eq!(get_desc(&item_for_filter(&f).unwrap(), "Fltr"), Some(&measured)); + // Defaults and hostile values write longs within the dialog's ranges. + let d = item_for_filter(&sf("filter.pixelate.colorHalftone", json!({"maxRadius": f64::NAN, "channel1": 1e12, "channel2": -1e12, "channel3": "x"}))) + .unwrap(); + let fltr = get_desc(&d, "Fltr").unwrap(); + let got: Vec<_> = ["Rds ", "Ang1", "Ang2", "Ang3", "Ang4"].iter().map(|k| int(fltr, k)).collect(); + assert_eq!(got, [Some(8), Some(360), Some(-360), Some(90), Some(45)]); + } + + #[test] + fn malformed_pixelate_filters_stay_verbatim() { + let mz = |v: Value| Descriptor::new("Mztn").with("MztT", v); + let ch = |rds: Value, ang1: Value| { + Descriptor::new("ClrH") + .with("Rds ", rds) + .with("Ang1", ang1) + .with("Ang2", Value::Integer(20)) + .with("Ang3", Value::Integer(30)) + .with("Ang4", Value::Integer(40)) + }; + let cases = [ + pixelate_item("Mezzotint...", Descriptor::new("Mztn"), code(b"Mztn")), + pixelate_item("Mezzotint...", mz(enumv("MztT", "Sprk")), code(b"Mztn")), + pixelate_item("Mezzotint...", mz(Value::Integer(1)), code(b"Mztn")), + pixelate_item("Color Halftone...", Descriptor::new("ClrH"), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Double(6.0), Value::Integer(10)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(unit(b"#Pxl", 6.0), Value::Integer(10)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(3), Value::Integer(10)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(128), Value::Integer(10)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(361)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(i32::MIN)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(6), Value::Double(f64::NAN)), code(b"ClrH")), + // A mistyped enum and settings we don't write back. + pixelate_item("Mezzotint...", mz(enumv("Xxxx", "FnDt")), code(b"Mztn")), + pixelate_item("Mezzotint...", mz(enumv("MztT", "FnDt")).with("Xtra", Value::Integer(1)), code(b"Mztn")), + pixelate_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(10)).with("Xtra", Value::Integer(1)), code(b"ClrH")), + pixelate_item("Color Halftone...", ch(Value::Integer(6), Value::Integer(10)).with("Ang1", Value::Integer(11)), code(b"ClrH")), + ]; + for item in cases { + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{:?}", get_desc(&item, "Fltr")); + // Written back with its own settings (blending is rewritten from the model). + let back = item_for_filter(&f).unwrap(); + // Compared as bytes: a NaN setting is not equal to itself. + let fltr = |d: &Descriptor| get_desc(d, "Fltr").map(Descriptor::to_bytes); + assert_eq!((fltr(&back), int(&back, "filterID")), (fltr(&item), int(&item, "filterID"))); + } + // Without a `Fltr` the filter has no settings to read: kept verbatim too. + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"ClrH"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") @@ -1103,6 +2603,341 @@ mod tests { assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); } + /// A `filterFXList` item with filter `fltr` and `filterID`, as Photoshop saves it. + fn ps_item(name: &str, fltr: Descriptor, filter_id: i32) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(filter_id)) + } + + fn surface_blur(rds: f64, thsh: i32) -> Descriptor { + Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", rds)).with("Thsh", Value::Integer(thsh)) + } + + fn smart_blur(rds: f64, thsh: f64, q: &str, m: &str) -> Descriptor { + Descriptor::new("SmrB").with("Rds ", Value::Double(rds)).with("Thsh", Value::Double(thsh)).with("SmBQ", enumv("SmBQ", q)).with("SmBM", enumv("SmBM", m)) + } + + fn radial_blur(amnt: i32, m: &str, q: &str) -> Descriptor { + Descriptor::new("RdlB").with("Amnt", Value::Integer(amnt)).with("BlrM", enumv("BlrM", m)).with("BlrQ", enumv("BlrQ", q)) + } + + /// The `Fltr` shapes measured in Photoshop (each filter applied to a smart object through + /// Action Manager, saved, and `filterFX` read back) open as our filters and are written back + /// with the same keys, value types, units, enums and filter ids. + #[test] + fn blur_family_matches_photoshop_shapes() { + let smart = code(b"SmrB"); + let radial = code(b"RdlB"); + assert_eq!((smart, radial), (1_399_681_602, 1_382_313_026)); + let cases = [ + (surface_blur(5.0, 15), 1135, "filter.blur.surfaceBlur", json!({"radius": 5.0, "threshold": 15})), + (surface_blur(12.0, 40), 1135, "filter.blur.surfaceBlur", json!({"radius": 12.0, "threshold": 40})), + ( + smart_blur(5.0, 25.0, "SBQH", "SBMN"), + smart, + "filter.blur.smartBlur", + json!({"radius": 5.0, "threshold": 25.0, "quality": "high", "mode": "normal"}), + ), + ( + smart_blur(3.0, 30.0, "SBQL", "SBME"), + smart, + "filter.blur.smartBlur", + json!({"radius": 3.0, "threshold": 30.0, "quality": "low", "mode": "edgeOnly"}), + ), + ( + smart_blur(3.0, 30.0, "SBQM", "SBMO"), + smart, + "filter.blur.smartBlur", + json!({"radius": 3.0, "threshold": 30.0, "quality": "medium", "mode": "overlayEdge"}), + ), + (radial_blur(10, "Spn ", "Drft"), radial, "filter.blur.radialBlur", json!({"amount": 10, "method": "spin", "quality": "draft"})), + (radial_blur(20, "Spn ", "Bst "), radial, "filter.blur.radialBlur", json!({"amount": 20, "method": "spin", "quality": "best"})), + (radial_blur(20, "Zm ", "Gd "), radial, "filter.blur.radialBlur", json!({"amount": 20, "method": "zoom", "quality": "good"})), + ]; + for (fltr, id, command, params) in cases { + let item = ps_item("x", fltr.clone(), id); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms), "{fltr:?}"); + let out = item_for_filter(&f).unwrap(); + assert_eq!(out.get("filterID"), Some(&Value::Integer(id))); + assert_eq!(out.get("hasoptions"), Some(&Value::Boolean(true))); + // Byte-identical filter descriptor: class id, four-char keys, types, units and enums. + let Some(Value::Descriptor(w)) = out.get("Fltr") else { panic!("no Fltr for {command}") }; + assert_eq!(w.to_bytes(), fltr.to_bytes(), "{command}"); + } + // Surface Blur's class is a string id; Smart Blur's and Radial Blur's are four-char codes. + assert_eq!(surface_blur(1.0, 2).class_id, Id::Str(b"surfaceBlur".to_vec())); + assert_eq!(radial_blur(1, "Spn ", "Gd ").class_id, Id::Code(*b"RdlB")); + // Unset options are written with the dialog defaults. + let fltr = |c: &str, p: J| item_for_filter(&sf(c, p)).unwrap().get("Fltr").cloned(); + assert_eq!(fltr("filter.blur.surfaceBlur", json!({})), Some(Value::Descriptor(surface_blur(5.0, 15)))); + assert_eq!(fltr("filter.blur.smartBlur", json!({})), Some(Value::Descriptor(smart_blur(3.0, 25.0, "SBQH", "SBMN")))); + assert_eq!(fltr("filter.blur.radialBlur", json!({"centerX": 0.5, "centerY": 0.5})), Some(Value::Descriptor(radial_blur(10, "Spn ", "Gd ")))); + } + + /// `d` with `key` set to `v` (replacing the measured value). + fn replaced(mut d: Descriptor, key: &str, v: Value) -> Descriptor { + set(&mut d, key, v); + d + } + + /// Malformed or unmodelled blur descriptors stay verbatim (and are written back unchanged); + /// settings PSD can't hold are reported, never a panic. + #[test] + fn blur_family_malformed_is_kept_verbatim() { + let smart = code(b"SmrB"); + let radial = code(b"RdlB"); + let text = |s: &str| Value::Text(UnicodeString::new_nul(s)); + let bad = [ + // Missing keys. + (Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", 5.0)), 1135), + (Descriptor::new("SmrB").with("Rds ", Value::Double(3.0)).with("Thsh", Value::Double(25.0)), smart), + (Descriptor::new("RdlB").with("Amnt", Value::Integer(10)).with("BlrM", enumv("BlrM", "Spn ")), radial), + // Wrong types. + (Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Pxl", 5.0)).with("Thsh", Value::Double(15.0)), 1135), + ( + Descriptor::new("SmrB") + .with("Rds ", text("3")) + .with("Thsh", Value::Double(25.0)) + .with("SmBQ", enumv("SmBQ", "SBQH")) + .with("SmBM", enumv("SmBM", "SBMN")), + smart, + ), + (Descriptor::new("RdlB").with("Amnt", text("10")).with("BlrM", enumv("BlrM", "Spn ")).with("BlrQ", enumv("BlrQ", "Gd ")), radial), + (Descriptor::new("RdlB").with("Amnt", Value::Integer(10)).with("BlrM", Value::Integer(1)).with("BlrQ", enumv("BlrQ", "Gd ")), radial), + // Out of range, NaN and infinity. + (surface_blur(0.5, 15), 1135), + (surface_blur(5.0, 300), 1135), + (surface_blur(f64::NAN, 15), 1135), + (smart_blur(f64::INFINITY, 25.0, "SBQH", "SBMN"), smart), + (smart_blur(3.0, 0.0, "SBQH", "SBMN"), smart), + (radial_blur(0, "Spn ", "Gd "), radial), + (radial_blur(101, "Zm ", "Gd "), radial), + // Unknown enum values. + (smart_blur(3.0, 25.0, "SBQX", "SBMN"), smart), + (smart_blur(3.0, 25.0, "Hgh ", "SBMN"), smart), + (radial_blur(10, "Twrl", "Gd "), radial), + (radial_blur(10, "Spn ", "Hgh "), radial), + // A key we don't model (an off-centre Radial Blur would carry one). + (radial_blur(10, "Zm ", "Gd ").with("Cntr", Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(10.0)))), radial), + (surface_blur(5.0, 15).with("Edg ", Value::Integer(1)), 1135), + // Enums of another type, units and value types we would not write back. + (replaced(smart_blur(3.0, 25.0, "SBQH", "SBMN"), "SmBQ", enumv("Othr", "SBQH")), smart), + (replaced(smart_blur(3.0, 25.0, "SBQH", "SBMN"), "SmBM", enumv("SmBQ", "SBMN")), smart), + (replaced(radial_blur(10, "Spn ", "Gd "), "BlrM", enumv("Othr", "Spn ")), radial), + (replaced(radial_blur(10, "Spn ", "Gd "), "BlrQ", enumv("BlrM", "Gd ")), radial), + (Descriptor::new("surfaceBlur").with("Rds ", unit(b"#Prc", 5.0)).with("Thsh", Value::Integer(15)), 1135), + (Descriptor::new("surfaceBlur").with("Rds ", Value::Double(5.0)).with("Thsh", Value::Integer(15)), 1135), + (replaced(smart_blur(3.0, 25.0, "SBQH", "SBMN"), "Rds ", unit(b"#Pxl", 3.0)), smart), + (replaced(smart_blur(3.0, 25.0, "SBQH", "SBMN"), "Thsh", unit(b"#Prc", 25.0)), smart), + (replaced(smart_blur(3.0, 25.0, "SBQH", "SBMN"), "Thsh", Value::Integer(25)), smart), + ]; + for (fltr, id) in bad { + let item = ps_item("x", fltr.clone(), id); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{fltr:?}"); + // Compared as bytes: a NaN setting is not equal to itself. + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "{fltr:?}"); + } + // Radial Blur at its defaults has no `Fltr` (measured): its values are not stored, so the + // entry stays verbatim instead of guessing them. + let no_opts = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(radial)); + assert_eq!(filter_from_item(&no_opts).command, UNSUPPORTED_FILTER); + // Settings with no PSD equivalent are errors when writing. + for (command, p) in [ + ("filter.blur.smartBlur", json!({"quality": "ultra"})), + ("filter.blur.smartBlur", json!({"mode": 3})), + ("filter.blur.radialBlur", json!({"method": "twirl"})), + ("filter.blur.radialBlur", json!({"quality": ["best"]})), + ("filter.blur.radialBlur", json!({"centerX": 0.25})), + ("filter.blur.radialBlur", json!({"centerY": 1.0})), + // A centre, or a value, that is there but can't be written as Photoshop would open it. + ("filter.blur.radialBlur", json!({"centerX": "middle"})), + ("filter.blur.radialBlur", json!({"centerY": true})), + ("filter.blur.radialBlur", json!({"amount": 0})), + ("filter.blur.radialBlur", json!({"amount": 101})), + ("filter.blur.radialBlur", json!({"amount": "10"})), + ("filter.blur.surfaceBlur", json!({"radius": 0.5})), + ("filter.blur.surfaceBlur", json!({"radius": 100.5})), + ("filter.blur.surfaceBlur", json!({"threshold": 1})), + ("filter.blur.surfaceBlur", json!({"threshold": 256})), + ("filter.blur.surfaceBlur", json!({"radius": "5"})), + ("filter.blur.smartBlur", json!({"radius": 0.05})), + ("filter.blur.smartBlur", json!({"threshold": 100.5})), + ("filter.blur.smartBlur", json!({"threshold": [25]})), + ] { + assert!(item_for_filter(&sf(command, p.clone())).is_err(), "{command} {p}"); + } + // Every value that is written opens again as the same editable filter (range ends included). + for (command, p) in [ + ("filter.blur.surfaceBlur", json!({"radius": 1.0, "threshold": 2})), + ("filter.blur.surfaceBlur", json!({"radius": 100.0, "threshold": 255})), + ("filter.blur.smartBlur", json!({"radius": 0.1, "threshold": 100.0, "quality": "low", "mode": "edgeOnly"})), + ("filter.blur.radialBlur", json!({"amount": 1, "method": "zoom", "quality": "best", "centerX": 0.5, "centerY": null})), + ("filter.blur.radialBlur", json!({"amount": 100, "method": "spin", "quality": "draft"})), + ] { + let back = filter_from_item(&item_for_filter(&sf(command, p.clone())).unwrap()); + assert_eq!(back.command, command, "{p}"); + } + // Hostile numbers write without panicking. + for p in [json!({"radius": f64::MAX, "threshold": -1e300, "amount": 1e300}), json!({"radius": "x", "threshold": null}), json!(null), json!([1, 2])] { + for command in ["filter.blur.surfaceBlur", "filter.blur.smartBlur", "filter.blur.radialBlur"] { + let _ = item_for_filter(&sf(command, p.clone())); + } + } + } + + /// A `filterFX` entry shaped as Photoshop saves it (measured in Photoshop: the filter applied + /// through Action Manager to a smart object, the PSD saved, `filterFX` read back). + fn photoshop_item(filter_id: &[u8; 4], fltr: Descriptor) -> Descriptor { + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter..."))) + .with("blendOptions", blend_options(&sf("", J::Null))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgbc(0.0)) + .with("BckC", rgbc(255.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(filter_id))) + } + + #[test] + fn stylize_and_pixelate_filters_open_from_photoshop_shapes() { + let cases = [ + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Nrml")), "filter.stylize.diffuse", json!({"mode": "normal"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "DrkO")), "filter.stylize.diffuse", json!({"mode": "darkenOnly"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "LghO")), "filter.stylize.diffuse", json!({"mode": "lightenOnly"})), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "anisotropic")), "filter.stylize.diffuse", json!({"mode": "anisotropic"})), + ( + b"TrcC", + Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Upr ")), + "filter.stylize.traceContour", + json!({"level": 128, "edge": "upper"}), + ), + ( + b"TrcC", + Descriptor::new("TrcC").with("Lvl ", Value::Integer(60)).with("Edg ", enumv("CntE", "Lwr ")), + "filter.stylize.traceContour", + json!({"level": 60, "edge": "lower"}), + ), + (b"Crst", Descriptor::new("Crst").with("ClSz", Value::Integer(10)), "filter.pixelate.crystallize", json!({"cellSize": 10})), + ( + b"Pntl", + Descriptor::new("Pntl").with("ClSz", Value::Integer(7)), + "filter.pixelate.pointillize", + json!({"cellSize": 7, "background": [1.0, 1.0, 1.0, 1.0]}), + ), + ]; + for (id, fltr, command, params) in cases { + let item = photoshop_item(id, fltr); + // Through bytes, as read from a file. + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back in the measured shape: same class, keys, value types and filterID. + let back = item_for_filter(&f).unwrap(); + for key in ["Fltr", "filterID", "BckC"] { + assert_eq!(back.get(key), item.get(key), "{command} {key}"); + } + } + // The `anisotropic` mode is a long id, as Photoshop writes it. + let d = item_for_filter(&sf("filter.stylize.diffuse", json!({"mode": "anisotropic"}))).unwrap(); + let Some(Value::Descriptor(fltr)) = d.get("Fltr") else { panic!("no Fltr") }; + assert!(matches!(fltr.get("Md "), Some(Value::Enumerated { value: Id::Str(v), .. }) if v == b"anisotropic")); + // Pointillize takes the entry's background colour; the others ignore it. + let mut item = photoshop_item(b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", rgbc(51.0)); + let f = filter_from_item(&item); + assert_eq!(f.params["background"], json!([0.2, 0.2, 0.2, 1.0])); + assert_eq!(item_for_filter(&f).unwrap().get("BckC"), Some(&rgbc(51.0))); + let mut item = photoshop_item(b"Crst", Descriptor::new("Crst").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", rgbc(51.0)); + assert_eq!(filter_from_item(&item).params, json!({"cellSize": 7})); + // Our random seed has no PSD key: it is not written. + let fltr = |c: &str, p: J| item_for_filter(&sf(c, p)).unwrap().get("Fltr").cloned(); + assert_eq!( + 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. + assert_eq!( + fltr("filter.stylize.traceContour", json!({})), + Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Lwr ")))) + ); + 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})), + Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(300)))) + ); + 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"})), + Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(0)).with("Edg ", enumv("CntE", "Upr ")))) + ); + } + + #[test] + fn malformed_stylize_and_pixelate_filters_are_kept_verbatim() { + let long = Value::Integer; + let cases = [ + // Missing keys. + (b"Dfs ", Descriptor::new("Dfs ")), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(60))), + (b"TrcC", Descriptor::new("TrcC").with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst")), + (b"Pntl", Descriptor::new("Pntl")), + // Unknown enum values or enum types. + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Wild"))), + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", enumv("BlnM", "Nrml"))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(60)).with("Edg ", enumv("CntE", "Mddl"))), + // Wrong types (also NaN). + (b"Dfs ", Descriptor::new("Dfs ").with("Md ", long(1))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", Value::Double(60.0)).with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst").with("ClSz", unit(b"#Pxl", 10.0))), + (b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Double(f64::NAN))), + // Out of range. + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(256)).with("Edg ", enumv("CntE", "Upr "))), + (b"TrcC", Descriptor::new("TrcC").with("Lvl ", long(-1)).with("Edg ", enumv("CntE", "Upr "))), + (b"Crst", Descriptor::new("Crst").with("ClSz", long(2))), + (b"Pntl", Descriptor::new("Pntl").with("ClSz", long(i32::MAX))), + ]; + for (id, fltr) in cases { + let item = photoshop_item(id, fltr); + let f = filter_from_item(&Descriptor::from_bytes(&item.to_bytes()).unwrap()); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{item:?}"); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes()); + } + // Saved without options (`hasoptions` false, no `Fltr`): kept verbatim. + let bare = Descriptor::new("filterFX").with("hasoptions", Value::Boolean(false)).with("filterID", Value::Integer(code(b"Crst"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + // A Pointillize entry whose background colour is unreadable is kept verbatim too. + for bad in [Value::Integer(3), Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))] { + let mut item = photoshop_item(b"Pntl", Descriptor::new("Pntl").with("ClSz", Value::Integer(7))); + set(&mut item, "BckC", bad); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + // Values with no PSD equivalent are reported, not guessed. + for (c, p) in [ + ("filter.stylize.diffuse", json!({"mode": "sideways"})), + ("filter.stylize.diffuse", json!({"mode": 3})), + ("filter.stylize.traceContour", json!({"edge": "middle"})), + ("filter.pixelate.pointillize", json!({"background": "teal"})), + ("filter.pixelate.pointillize", json!({"background": [1.0]})), + ] { + assert!(item_for_filter(&sf(c, p.clone())).is_err(), "{c} {p}"); + } + } + /// A Filter Gallery item as Photopea writes it (Chrome, Detail 12, Smoothness 4). fn gallery_item_with(effects: Vec) -> Descriptor { let rgb = @@ -1354,14 +3189,14 @@ mod tests { let mut w = Vec::new(); let mut spec = PlacedSpec { idnt: "id", placed: "pl", transform: t, perspective: None, size: (20.0, 10.0), dpi: 72.0, warp: None, filter_fx: None }; let template = parse_sold(&sold_bytes(None, &spec, &mut w)).expect("parses").descriptor; - assert!(!quad_moved(&template, &spec), "same placement"); + assert!(!quad_moved(&template, &spec, spec.warp), "same placement"); spec.transform = Affine { m: [1.0, 0.0, 0.0, 1.0, 3.5, 4.0] }; - assert!(quad_moved(&template, &spec), "moved half a pixel"); + assert!(quad_moved(&template, &spec, spec.warp), "moved half a pixel"); spec.transform = t; spec.perspective = photocraft_algo::transform::Homography::rect_to_quad([0.0, 0.0, 20.0, 10.0], [[3.0, 4.0], [23.0, 4.0], [20.0, 12.0], [3.0, 14.0]]).map(|h| h.0); - assert!(quad_moved(&template, &spec), "one corner moved (Distort)"); - assert!(quad_moved(&Descriptor::new("null"), &spec), "no stored quad"); + assert!(quad_moved(&template, &spec, spec.warp), "one corner moved (Distort)"); + assert!(quad_moved(&Descriptor::new("null"), &spec, spec.warp), "no stored quad"); } /// Corrupted placed-layer data and filter descriptors never panic: they parse to something @@ -1417,13 +3252,15 @@ mod tests { let mut custom = BezierMesh::identity(bounds, 1, 1); custom.points[15] = [67.0, 30.0]; let mut multi = Warp::preset(WarpStyle::Custom, 0.0, bounds); - multi.mesh = Some(BezierMesh::identity(bounds, 2, 2)); + let mut split = BezierMesh::identity(bounds, 2, 2); + split.points[8][1] += 4.0; + multi.mesh = Some(split); let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds); arc.vertical = true; arc.h_distort = 10.0; let mut c = Warp::preset(WarpStyle::Custom, 0.0, bounds); c.mesh = Some(custom); - for (w, back) in [(Some(arc.clone()), Some(arc)), (Some(c.clone()), Some(c)), (Some(multi), None), (None, None)] { + for (w, back) in [(Some(arc.clone()), Some(arc)), (Some(c.clone()), Some(c)), (Some(multi.clone()), Some(multi)), (None, None)] { let mut warnings = Vec::new(); let spec = PlacedSpec { idnt: "i", @@ -1437,10 +3274,216 @@ mod tests { }; let sold = sold_bytes(None, &spec, &mut warnings); assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), back); - assert_eq!(warnings.len(), usize::from(w.is_some() && back.is_none())); + assert!(warnings.is_empty(), "{warnings:?}"); } } + /// A placed layer over a 50 × 25 source, at `transform`, with `warp`. + fn placed<'a>(transform: Affine, warp: Option<&'a Warp>) -> PlacedSpec<'a> { + PlacedSpec { idnt: "i", placed: "p", transform, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp, filter_fx: None } + } + + /// A 3 × 2 patch split warp over the 50 × 25 source with one interior control point pulled. + fn split_warp() -> Warp { + use photocraft_geom::warp::BezierMesh; + let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2); + mesh.points[10 * 3 + 4][1] += 3.0; + Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]) + } + + /// An imported `soLD` descriptor holding `warp` as Photoshop writes a split warp: a + /// `quiltWarp` whose outer slices sit 0.6 px past the source, before a `warpNone` `warp`. + fn photoshop_template(warp: &Warp) -> Descriptor { + let mesh = warp.mesh.as_ref().expect("a mesh"); + let slices = |k: &str, knots: &[f64], len: f64| { + let n = knots.len() - 1; + let values = knots + .iter() + .enumerate() + .map(|(i, u)| { + if i == 0 { + -0.6 + } else if i == n { + len + 0.6 + } else { + u * len + } + }) + .collect::>(); + let prefix = values.len() as u32; + Value::ObjectArray(ObjectArray { prefix, body: Descriptor::new("UntF").with(k, Value::UnitFloats { unit: *b"#Pxl", values }) }) + }; + let Value::Descriptor(plain) = warp_desc(None, (50.0, 25.0), &mut Vec::new()) else { panic!("a descriptor") }; + let mut quilt = plain.clone(); + quilt.class_id = Id::new("quiltWarp"); + set(&mut quilt, "warpStyle", enumv("warpStyle", "warpCustom")); + let points = Descriptor::new("rationalPoint") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: mesh.points.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: mesh.points.iter().map(|p| p[1]).collect() }); + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", &mesh.us, 50.0)) + .with("quiltSliceY", slices("quiltSliceY", &mesh.vs, 25.0)) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: mesh.points.len() as u32, body: points })); + quilt = quilt + .with("deformNumRows", Value::Integer(mesh.ny() as i32)) + .with("deformNumCols", Value::Integer(mesh.nx() as i32)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)); + let mut d = sold_descriptor(&sold_bytes(None, &placed(Affine::IDENTITY, None), &mut Vec::new())).expect("a descriptor"); + let at = d.items.iter().position(|(k, _)| k.is("warp")).expect("a warp key"); + d.items.insert(at, (Id::new("quiltWarp"), Value::Descriptor(quilt))); + d + } + + fn sold_of(d: Descriptor) -> Vec { + let mut out = b"soLD".to_vec(); + out.extend_from_slice(&4u32.to_be_bytes()); + out.extend(VersionedDescriptor::new(d).to_bytes()); + out + } + + fn quilt_of(d: &Descriptor) -> Option<&Descriptor> { + get_desc(d, "quiltWarp") + } + + /// A split warp of more patches along a side than a `quiltWarp` is read with (Warp Grid 64, + /// then one more split) is written as no warp, with a message, framed by the source rect, so + /// the layer reopens where it was. Before, its `quiltWarp` was refused on reopen while `Trnf` + /// still framed its control box: the layer came back stretched and shifted. + #[test] + fn a_split_warp_past_the_quilt_limit_keeps_its_placement() { + use photocraft_geom::warp::BezierMesh; + let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], crate::blocks::QUILT_MAX_PATCHES, 1); + assert!(mesh.split_u(0.005)); + mesh.points[0] = [-10.0, -5.0]; + let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]); + let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] }; + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), None); + assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none()); + // Over an imported split warp, the stale `quiltWarp` goes and the placement holds. + let template = photoshop_template(&split_warp()); + let mut warnings = Vec::new(); + let sold = sold_bytes(Some(&template), &placed(transform, Some(&warp)), &mut warnings); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform); + assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none()); + // So does one this reader can't read, which Photoshop would still apply. + let mut template = photoshop_template(&split_warp()); + let Some((_, Value::Descriptor(q))) = template.items.iter_mut().find(|(k, _)| k.is("quiltWarp")) else { panic!("a quiltWarp") }; + set(q, "deformNumRows", Value::Integer(0)); + let sold = sold_bytes(Some(&template), &placed(transform, Some(&warp)), &mut Vec::new()); + assert!(quilt_of(&sold_descriptor(&sold).expect("a descriptor")).is_none()); + // At the limit the split warp is still written. + let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], crate::blocks::QUILT_MAX_PATCHES, 1); + mesh.points[0] = [-10.0, -5.0]; + let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]); + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp)); + } + + /// A split (multi-patch) custom warp is written as Photoshop writes it, a `quiltWarp` before a + /// `warpNone` `warp`, and reads back with its mesh and its transform. Before, the warp was + /// written as none while `Trnf` was still the image of its control-point box, so the layer + /// came back stretched and shifted. + #[test] + fn split_warp_round_trips_through_sold() { + use photocraft_geom::warp::BezierMesh; + let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 2, 1); + mesh.points[0] = [-10.0, -5.0]; + let warp = Warp::custom(mesh, [0.0, 0.0, 50.0, 25.0]); + let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] }; + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &placed(transform, Some(&warp)), &mut warnings); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, transform); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp)); + let d = sold_descriptor(&sold).expect("a descriptor"); + let at = |key: &str| d.items.iter().position(|(k, _)| k.is(key)); + assert!(at("quiltWarp").expect("a quiltWarp") < at("warp").expect("a warp")); + let plain = get_desc(&d, "warp").expect("a warp"); + assert_eq!(enum_of(plain, "warpStyle"), Some(&b"warpNone"[..])); + assert!(plain.get("customEnvelopeWarp").is_none()); + let quilt = quilt_of(&d).expect("a quiltWarp"); + assert!(quilt.class_id.is("quiltWarp")); + assert_eq!((quilt.get("deformNumCols"), quilt.get("deformNumRows")), (Some(&Value::Integer(7)), Some(&Value::Integer(4)))); + let env = get_desc(quilt, "customEnvelopeWarp").expect("an envelope"); + let prefix = |k: &str| match env.get(k) { + Some(Value::ObjectArray(a)) => Some(a.prefix), + _ => None, + }; + assert_eq!((prefix("quiltSliceX"), prefix("quiltSliceY"), prefix("meshPoints")), (Some(3), Some(2), Some(28))); + } + + /// A template's split warp is replaced along with its `warp`: a warp changed to Arc, to none, + /// to one patch or to another mesh reads back as changed, not as the imported split warp. + #[test] + fn a_replaced_split_warp_does_not_come_back() { + use photocraft_geom::warp::BezierMesh; + let bounds = [0.0, 0.0, 50.0, 25.0]; + let split = split_warp(); + let template = photoshop_template(&split); + let arc = Warp::preset(WarpStyle::Arc, 30.0, bounds); + let mut one = BezierMesh::identity(bounds, 1, 1); + one.points[5][0] += 2.0; + let one = Warp::custom(one, bounds); + let mut resplit = BezierMesh::identity(bounds, 2, 2); + resplit.points[8][1] += 4.0; + let resplit = Warp::custom(resplit, bounds); + for w in [Some(arc), None, Some(one), Some(resplit)] { + let mut warnings = Vec::new(); + let sold = sold_bytes(Some(&template), &placed(Affine::IDENTITY, w.as_ref()), &mut warnings); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), w); + let d = sold_descriptor(&sold).expect("a descriptor"); + assert_eq!(quilt_of(&d).is_some(), w.as_ref().is_some_and(|w| w.mesh.as_ref().is_some_and(|m| m.points.len() > 16)), "{w:?}"); + assert!(warnings.is_empty(), "{warnings:?}"); + } + } + + /// An imported split warp that nothing changed keeps Photoshop's `quiltWarp` as stored when + /// the layer is rewritten for something else (here a move); a mesh edited on the same grid + /// keeps Photoshop's slices. + #[test] + fn an_imported_split_warp_keeps_photoshop_s_quilt() { + let template = photoshop_template(&split_warp()); + let read = crate::blocks::parse_placed_warp(b"SoLd", &sold_of(template.clone())).expect("the split warp"); + let moved = Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] }; + let sold = sold_bytes(Some(&template), &placed(moved, Some(&read)), &mut Vec::new()); + let d = sold_descriptor(&sold).expect("a descriptor"); + assert_eq!(d.get("quiltWarp"), template.get("quiltWarp")); + assert_eq!(crate::blocks::parse_smart(b"SoLd", &sold).1, moved); + let mut edited = read.clone(); + if let Some(m) = edited.mesh.as_mut() { + m.points[12][0] += 1.5; + } + let sold = sold_bytes(Some(&template), &placed(Affine::IDENTITY, Some(&edited)), &mut Vec::new()); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(edited)); + let slices = |d: &Descriptor| { + let env = get_desc(quilt_of(d)?, "customEnvelopeWarp")?; + Some((env.get("quiltSliceX").cloned(), env.get("quiltSliceY").cloned())) + }; + assert_eq!(slices(&sold_descriptor(&sold).expect("a descriptor")), slices(&template)); + } + + /// The legacy `PlLd` record has no split warp: it stores none, with its corners framed by the + /// source rect, so a reader of `PlLd` alone places the layer where its transform puts it. + #[test] + fn plld_of_a_split_warp_is_framed_by_the_source() { + let warp = split_warp(); + let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] }; + let mut warnings = Vec::new(); + let plld = plld_bytes(&placed(transform, Some(&warp)), &mut warnings); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(crate::blocks::parse_plld_warp(&plld), crate::blocks::PlldWarp::None); + let (_, corners) = crate::blocks::parse_plld(&plld).expect("a record"); + assert_eq!(crate::blocks::plld_transform(&corners, Some((50.0, 25.0)), None).0, transform); + } + /// Export writes `Trnf` as the image of the custom warp's control-point box, the inverse of /// import: a spilled mesh comes back with the same transform and the quad Photoshop expects. #[test] @@ -1460,7 +3503,7 @@ mod tests { assert_eq!(q, [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]); assert_eq!(p.transform, transform); // The imported quad reads as unmoved, so the template's `Trnf` is kept as stored. - assert!(!quad_moved(&p.descriptor, &spec)); + assert!(!quad_moved(&p.descriptor, &spec, spec.warp)); } #[test] @@ -1473,4 +3516,140 @@ mod tests { assert_eq!(u, uuid_from(b"x")); assert_ne!(u, uuid_from(b"y")); } + + // Render filters as Photoshop 27 saves them on a smart object (`filterFXList` items, with + // neutral blending): Clouds and Difference Clouds in white on black, Fibers in black on white, + // Lens Flare with the 50-300mm Zoom at 159%. + const PHOTOSHOP_CLOUDS: &str = "0000000100000000000866696c746572465800000008000000004e6d202054455854000000070043006c006f00750064007300000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f7562000000000000000000000000466c74724f626a63000000070043006c006f007500640073000000000000436c64730000000100000000466c52736c6f6e67000049e40000000866696c74657249446c6f6e67436c6473"; + const PHOTOSHOP_DIFFERENCE_CLOUDS: &str = "0000000100000000000866696c746572465800000008000000004e6d202054455854000000120044006900660066006500720065006e0063006500200043006c006f00750064007300000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f7562000000000000000000000000466c74724f626a63000000120044006900660066006500720065006e0063006500200043006c006f007500640073000000000000446672430000000100000000466c52736c6f6e67000057d20000000866696c74657249446c6f6e6744667243"; + const PHOTOSHOP_FIBERS: &str = "0000000100000000000866696c746572465800000008000000004e6d2020544558540000000a004600690062006500720073002e002e002e00000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f756200000000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe0000000000000000000466c74724f626a630000000700460069006200650072007300000000000046627273000000030000000056726e636c6f6e670000001000000000537472676c6f6e670000000400000000526e64536c6f6e67000062f10000000866696c74657249446c6f6e6746627273"; + const PHOTOSHOP_LENS_FLARE: &str = "0000000100000000000866696c746572465800000008000000004e6d2020544558540000000e004c0065006e007300200046006c006100720065002e002e002e00000000000c626c656e644f7074696f6e734f626a63000000000000000c626c656e644f7074696f6e7300000002000000004f706374556e7446235072634059000000000000000000004d642020656e756d00000000426c6e4d000000066e6f726d616c00000000656e6162626f6f6c010000000a6861736f7074696f6e73626f6f6c0100000000467267434f626a630000000100000000000052474243000000030000000052642020646f756200000000000000000000000047726e20646f7562000000000000000000000000426c2020646f756200000000000000000000000042636b434f626a630000000100000000000052474243000000030000000052642020646f7562406fe000000000000000000047726e20646f7562406fe0000000000000000000426c2020646f7562406fe0000000000000000000466c74724f626a630000000b004c0065006e007300200046006c0061007200650000000000004c6e73460000000300000000427267686c6f6e670000009f00000000466c72434f626a6300000001000000000000506e7420000000020000000048727a6e646f75623fe86666600000000000000056727463646f75623fe60b60c0000000000000004c6e7320656e756d000000004c6e7320000000005a6d20200000000866696c74657249446c6f6e674c6e7346"; + + fn photoshop(hex: &str) -> Descriptor { + Descriptor::from_bytes(&from_hex(hex).unwrap()).unwrap() + } + + fn set_lens(item: &mut Descriptor, code: [u8; 4]) { + let Some(Value::Descriptor(fltr)) = item.items.iter_mut().find(|(k, _)| k.is("Fltr")).map(|(_, v)| v) else { panic!("no Fltr") }; + set(fltr, "Lns ", Value::Enumerated { type_id: Id::new("Lns "), value: Id::Code(code) }); + } + + #[test] + fn photoshop_render_filters_read_and_write_like_photoshop() { + let colours = |fg: f64, bg: f64| (json!([fg, fg, fg, 1.0]), json!([bg, bg, bg, 1.0])); + let (white, black) = colours(1.0, 0.0); + let cases = [ + (PHOTOSHOP_CLOUDS, "filter.render.clouds", json!({"seed": 18916, "foreground": white, "background": black})), + (PHOTOSHOP_DIFFERENCE_CLOUDS, "filter.render.differenceClouds", json!({"seed": 22482, "foreground": white, "background": black})), + (PHOTOSHOP_FIBERS, "filter.render.fibers", json!({"variance": 16, "strength": 4, "seed": 25329, "foreground": black, "background": white})), + ( + PHOTOSHOP_LENS_FLARE, + "filter.render.lensFlare", + json!({"brightness": 159, "centerX": 0.762499988079071, "centerY": 0.6888889074325562, "lens": "zoom"}), + ), + ]; + for (hex, command, params) in cases { + let item = photoshop(hex); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), (command, ¶ms)); + // Written back, it is Photoshop's item again: name, colours, settings and key order. + assert_eq!(item_for_filter(&f).unwrap().items, item.items, "{command}"); + } + } + + #[test] + fn every_lens_flare_lens_round_trips_with_photoshops_code() { + for (code, lens) in LENSES { + let mut item = photoshop(PHOTOSHOP_LENS_FLARE); + set_lens(&mut item, *code); + let f = filter_from_item(&item); + assert_eq!(f.params["lens"], lens); + assert_eq!(item_for_filter(&f).unwrap().items, item.items, "{lens}"); + } + // A lens we don't know keeps the filter verbatim. + let mut item = photoshop(PHOTOSHOP_LENS_FLARE); + set_lens(&mut item, *b"Xxxx"); + assert_eq!(filter_from_item(&item).command, UNSUPPORTED_FILTER); + } + + /// The descriptor at `path` (nested keys) inside `item`, for editing. + fn desc_at<'a>(item: &'a mut Descriptor, path: &[&str]) -> &'a mut Descriptor { + path.iter().fold(item, |d, key| { + let Some(Value::Descriptor(x)) = d.items.iter_mut().find(|(k, _)| k.is(key)).map(|(_, v)| v) else { panic!("no {key}") }; + x + }) + } + + #[test] + fn render_filters_with_settings_we_dont_model_stay_verbatim() { + // Each case changes one thing in a Photoshop item: an extra setting, a repeated one, or a + // value of another type than Photoshop writes. Opened as editable, a re-save would drop the + // extra setting or rewrite the value; kept verbatim, the item is written back byte for byte. + type Edit = Box; + /// A descriptor path in the item (nested keys) and the edit made there. + type Change = (&'static [&'static str], Edit); + let extra: fn(&mut Descriptor) = |d| d.items.push((Id::new("Xtra"), Value::Integer(1))); + let as_double = |key: &'static str| -> Edit { Box::new(move |d| set(d, key, Value::Double(1.0))) }; + let as_long = |key: &'static str| -> Edit { Box::new(move |d| set(d, key, Value::Integer(1))) }; + let repeat = |key: &'static str| -> Edit { + Box::new(move |d| { + let v = d.get(key).cloned().unwrap(); + d.items.push((Id::new(key), v)); + }) + }; + let colour_edits = || -> Vec { + vec![ + (&["FrgC"], Box::new(|d: &mut Descriptor| d.items.push((Id::new("redFloat"), Value::Double(0.5))))), + (&["BckC"], as_long("Grn ")), + (&["FrgC"], Box::new(|d: &mut Descriptor| set(d, "Rd ", Value::Double(300.0)))), + (&["BckC"], Box::new(|d: &mut Descriptor| d.class_id = Id::new("HSBC"))), + ] + }; + let mut cases: Vec<(&str, &'static [&'static str], Edit)> = Vec::new(); + for hex in [PHOTOSHOP_CLOUDS, PHOTOSHOP_DIFFERENCE_CLOUDS] { + cases.push((hex, &["Fltr"], Box::new(extra))); + cases.push((hex, &["Fltr"], repeat("FlRs"))); + cases.push((hex, &["Fltr"], as_double("FlRs"))); + cases.extend(colour_edits().into_iter().map(|(p, e)| (hex, p, e))); + } + cases.push((PHOTOSHOP_FIBERS, &["Fltr"], Box::new(extra))); + cases.push((PHOTOSHOP_FIBERS, &["Fltr"], repeat("Strg"))); + for key in ["Vrnc", "Strg", "RndS"] { + cases.push((PHOTOSHOP_FIBERS, &["Fltr"], as_double(key))); + } + cases.extend(colour_edits().into_iter().map(|(p, e)| (PHOTOSHOP_FIBERS, p, e))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr"], Box::new(extra))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr"], repeat("Brgh"))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr"], as_double("Brgh"))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr"], Box::new(|d| set(d, "Lns ", Value::Enumerated { type_id: Id::new("Xxxx"), value: Id::Code(*b"Zm ") })))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr", "FlrC"], Box::new(extra))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr", "FlrC"], as_long("Hrzn"))); + cases.push((PHOTOSHOP_LENS_FLARE, &["Fltr", "FlrC"], Box::new(|d| set(d, "Vrtc", Value::UnitFloat { unit: *b"#Pxl", value: 0.5 })))); + for (i, (hex, path, edit)) in cases.iter().enumerate() { + let mut item = photoshop(hex); + edit(desc_at(&mut item, path)); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "case {i} ({path:?}): {:?}", f.params); + assert_eq!(item_for_filter(&f).unwrap().to_bytes(), item.to_bytes(), "case {i} ({path:?})"); + } + } + + #[test] + fn render_filters_made_in_photocraft_are_written() { + // Without colours (an older document or a script), Photoshop's default black and white. + let back = filter_from_item(&item_for_filter(&sf("filter.render.clouds", json!({"seed": 7}))).unwrap()); + assert_eq!(back.params, json!({"seed": 7, "foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]})); + // A seed above i32::MAX keeps its bit pattern, and colours may be hex. + let p = json!({"variance": 30, "strength": 10, "seed": 4_000_000_000u32, "foreground": "#ff0000", "background": [0.0, 0.0, 1.0, 1.0]}); + let back = filter_from_item(&item_for_filter(&sf("filter.render.fibers", p)).unwrap()); + assert_eq!( + back.params, + json!({"variance": 30, "strength": 10, "seed": 4_000_000_000u32, "foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]}) + ); + let flare = sf("filter.render.lensFlare", json!({"brightness": 120, "centerX": 0.25, "centerY": 0.75, "lens": "moviePrime"})); + assert_eq!(filter_from_item(&item_for_filter(&flare).unwrap()).params, flare.params); + // Lenses and colours we can't write are reported, not guessed. + assert!(item_for_filter(&sf("filter.render.lensFlare", json!({"lens": "fisheye"}))).unwrap_err().contains("fisheye")); + assert!(item_for_filter(&sf("filter.render.clouds", json!({"foreground": "red"}))).is_err()); + } } diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 72b2318675..ac1bd27ffa 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,10 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Aggregate memory for retained fallback surfaces in the document's pixel format. +const MAX_PIXEL_BYTES: u64 = 1 << 30; +/// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles). +const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; /// Past this many drawable elements the whole drawing opens as one pixel layer. const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). @@ -61,11 +65,75 @@ fn fonts() -> Arc { } #[cfg(not(target_arch = "wasm32"))] db.load_system_fonts(); + resolve_generic_families(&mut db, system_generic_family); Arc::new(db) }) .clone() } +/// Installed families that stand in for each generic one, best first: the metric-compatible +/// Liberation and Croscore fonts, then common Linux defaults, then the bundled craft fonts. +const SANS_SERIF: &[&str] = &["Liberation Sans", "Arimo", "Helvetica", "DejaVu Sans", "Noto Sans", photocraft_text::fonts::DEFAULT_FAMILY]; +const SERIF: &[&str] = &["Liberation Serif", "Tinos", "Times", "DejaVu Serif", "Noto Serif"]; +const MONOSPACE: &[&str] = &["Liberation Mono", "Cousine", "Courier", "DejaVu Sans Mono", "Noto Sans Mono", photocraft_text::fonts::MONO_FAMILY]; + +/// Points the generic families (`sans-serif`, `serif`, `monospace`, `cursive`, `fantasy`) at +/// fonts the database holds. fontdb names Windows fonts for them (Arial, Times New Roman, +/// Courier New…): on a system without those, every family of a usual +/// `font-family="Arial, Helvetica, sans-serif"` misses, and usvg drops the text although +/// hundreds of fonts are installed, where a browser sets it in a fallback font. A generic whose +/// font is installed is left alone. Otherwise the system's choice (`system`, Fontconfig on Linux) +/// wins when the database has it, then the first installed stand-in, then the sans-serif choice, +/// then any font at all. +fn resolve_generic_families(db: &mut usvg::fontdb::Database, system: impl Fn(&str) -> Option) { + use usvg::fontdb::Family; + let has = |db: &usvg::fontdb::Database, name: &str| db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))); + let Some(any) = db.faces().find_map(|f| f.families.first()).map(|(n, _)| n.clone()) else { return }; + let mut sans = None; + for (generic, css, stand_ins) in [ + (Family::SansSerif, "sans-serif", SANS_SERIF), + (Family::Serif, "serif", SERIF), + (Family::Monospace, "monospace", MONOSPACE), + (Family::Cursive, "cursive", &[][..]), + (Family::Fantasy, "fantasy", &[][..]), + ] { + let current = db.family_name(&generic).to_string(); + let pick = if has(db, ¤t) { + current + } else { + system(css) + .filter(|n| has(db, n)) + .or_else(|| stand_ins.iter().find(|n| has(db, n)).map(|n| n.to_string())) + .or_else(|| sans.clone()) + .unwrap_or_else(|| any.clone()) + }; + match generic { + Family::SansSerif => db.set_sans_serif_family(pick.clone()), + Family::Serif => db.set_serif_family(pick.clone()), + Family::Monospace => db.set_monospace_family(pick.clone()), + Family::Cursive => db.set_cursive_family(pick.clone()), + _ => db.set_fantasy_family(pick.clone()), + } + if generic == Family::SansSerif { + sans = Some(pick); + } + } +} + +/// The family Fontconfig sets a generic family in (`fc-match sans-serif`), as the rest of the +/// desktop does; `None` without Fontconfig. +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn system_generic_family(generic: &str) -> Option { + let out = std::process::Command::new("fc-match").args(["--format=%{family[0]}", generic]).output().ok()?; + let name = String::from_utf8(out.stdout).ok()?; + Some(name.trim().to_string()).filter(|n| out.status.success() && !n.is_empty()) +} + +#[cfg(not(all(target_os = "linux", not(target_arch = "wasm32"))))] +fn system_generic_family(_generic: &str) -> Option { + None +} + /// 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 @@ -212,6 +280,10 @@ pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: us } fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result { + import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES) +} + +fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result { let Parsed { tree, fontless_text } = parsed; let mut warnings = Vec::new(); if fontless_text { @@ -222,7 +294,8 @@ fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); @@ -311,6 +384,10 @@ struct Conv { noted: Vec<&'static str>, /// Unnamed shapes, groups, images and texts so far, for "Shape 3"-style names. counts: [usize; 4], + /// Remaining budget for retained rasterised surfaces, in actual sample bytes. + pixel_budget: u64, + /// Successfully retained raster surface memory; rejected nodes are never counted. + retained_pixel_bytes: u64, } impl Conv { @@ -321,6 +398,23 @@ impl Conv { } } + /// Check both retained and transient memory before rendering; charge only retained output. + fn can_retain_pixels(&mut self, pixels: u64) -> bool { + let bytes = pixels.saturating_mul(self.fmt.bytes_per_pixel() as u64); + if bytes > self.pixel_budget { + self.note("pixel-budget", "SVG: some rasterised elements were omitted because they exceeded the remaining pixel memory budget".into()); + return false; + } + // surface() converts the RGBA8 pixmap into f32 scratch space before writing + // sample-format tiles. All three buffers briefly coexist with retained layers. + let temporary = pixels.saturating_mul(4 + self.fmt.channels() as u64 * 4 + self.fmt.bytes_per_pixel() as u64); + if self.retained_pixel_bytes.saturating_add(temporary) > MAX_PEAK_PIXEL_BYTES { + self.note("pixel-peak", "SVG: some rasterised elements were omitted to bound temporary conversion memory".into()); + return false; + } + true + } + /// The element's `id`, or a numbered name by kind (0 shape, 1 group, 2 image, 3 text). fn name(&mut self, id: &str, kind: usize) -> String { if !id.is_empty() { @@ -567,9 +661,19 @@ impl Conv { if x1 <= x0 || y1 <= y0 { return None; } - let Some(mut pixmap) = pixmap((x1 - x0) as u32, (y1 - y0) as u32) else { + let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); + let pixels = u64::from(w) * u64::from(h); + // Preflight the pixmap limit first, without charging the shared allowance. + if pixels > MAX_RASTER_PIXELS { self.note("raster-size", "SVG: an element too large to rasterise was dropped".to_string()); return None; + } + if !self.can_retain_pixels(pixels) { + return None; + } + let Some(mut pixmap) = pixmap(w, h) else { + self.note("raster-alloc", "SVG: a rasterised element could not be allocated".to_string()); + return None; }; // resvg renders the node in its parent's space after shifting its absolute bounding box // to the origin: undo that shift, apply the parent's transform, then scale to the canvas @@ -577,7 +681,11 @@ impl Conv { let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32)); let ts = canvas.pre_concat(parent).pre_translate(shift.x(), shift.y()); resvg::render_node(node, ts, &mut pixmap.as_mut())?; - Some(Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt)))) + let layer = Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt))); + let bytes = pixels * self.fmt.bytes_per_pixel() as u64; + self.pixel_budget -= bytes; + self.retained_pixel_bytes += bytes; + Some(layer) } } @@ -736,6 +844,64 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text + /// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the + /// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the + /// text went off the canvas or kept its untransformed place and size. + /// + /// Shaped with the bundled Inter alone, never a system font: on a machine without fonts (a + /// bare CI image or VM) the test would otherwise have nothing to check, and the drawing and + /// its reference come from the same parse, so both use that face. + #[test] + fn transformed_text_lands_where_resvg_draws_it() { + fn inter() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(photocraft_text::fonts::INTER_REGULAR.to_vec()); + Arc::new(db) + } + assert!(inter().faces().next().is_some(), "the bundled Inter loads"); + let font = r#"font-size="24" font-family="Inter" fill="black""#; + let cases = [ + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + ]; + for c in cases { + let svg = format!(r#"{c}"#); + let parsed = parse_with(svg.as_bytes(), inter).unwrap(); + assert!(!parsed.fontless_text, "{c}"); + let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap(); + resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut()); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let [layer] = r.document.layers.as_slice() else { panic!("one text layer, got {}: {c}", r.document.layers.len()) }; + assert!(matches!(layer.content, LayerContent::Group(_)), "text stays outline shapes: {c}"); + let imported = photocraft_compose::flatten(&r.document); + let (mut both, mut either) = (0usize, 0usize); + let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); + let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; + for (i, px) in reference.pixels().iter().enumerate() { + let (x, y) = ((i % 120) as i32, (i / 120) as i32); + let (a, b) = (imported.get(x, y)[3] > 0.5, px.alpha() > 127); + if a { + grow(&mut a_box, x, y); + } + if b { + grow(&mut b_box, x, y); + } + both += usize::from(a && b); + either += usize::from(a || b); + } + assert!(either > 50, "resvg draws the text on the canvas: {c}"); + // Same place and size to the pixel; the outlines are filled by a different rasteriser, + // so the edges of thin stems may differ by half a pixel. + assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); + assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); + } + } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of /// two shapes is still rasterised, with the warning. @@ -784,6 +950,106 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + /// A database with the bundled Inter alone, generics resolved without Fontconfig: none of + /// fontdb's Windows defaults (Arial, Times New Roman, Courier New) is in it. + fn inter_only() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + resolve_generic_families(&mut db, |_| None); + Arc::new(db) + } + + /// Text asking for fonts that aren't installed (`Arial, Helvetica, sans-serif` on a Linux + /// without them) is set in an installed font, as a browser does, instead of being dropped. + #[test] + fn text_in_missing_fonts_falls_back_to_an_installed_one() { + for family in ["Arial, Helvetica, sans-serif", "Times New Roman", "Courier New, monospace", "cursive", "fantasy"] { + let svg = format!( + "II" + ); + let parsed = parse_with(svg.as_bytes(), inter_only).unwrap(); + assert!(!parsed.fontless_text); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings); + } + } + + #[test] + fn generic_families_point_at_installed_fonts() { + use usvg::fontdb::Family; + let db = inter_only(); + for g in [Family::SansSerif, Family::Serif, Family::Monospace, Family::Cursive, Family::Fantasy] { + assert_eq!(db.family_name(&g), "Inter", "{g:?}"); + } + // The system's choice wins when installed; a generic whose font is installed stays. + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec()); + db.set_cursive_family("Inter"); + resolve_generic_families(&mut db, |g| Some(if g == "serif" { "JetBrains Mono" } else { "Not Installed" }.to_string())); + assert_eq!(db.family_name(&Family::Serif), "JetBrains Mono"); + assert_eq!(db.family_name(&Family::Monospace), "JetBrains Mono", "the bundled mono stand-in"); + assert_eq!(db.family_name(&Family::SansSerif), "Inter"); + assert_eq!(db.family_name(&Family::Cursive), "Inter"); + // An empty database is left alone. + let mut empty = usvg::fontdb::Database::new(); + resolve_generic_families(&mut empty, |_| Some("Inter".into())); + assert_eq!(empty.family_name(&Family::SansSerif), "Arial"); + } + + #[test] + fn rasterized_groups_stop_at_the_aggregate_pixel_budget() { + let mut svg = String::from(""); + svg.push_str(""); + for i in 0..8 { + svg.push_str(&format!("")); + } + svg.push_str(""); + // Enough for one retained 256×256 RGBA8 surface, not eight of them. + let budget = 256u64 * 256 * 4; + let result = import_parsed_with_budget("budget", parse(svg.as_bytes()).unwrap(), MAX_GROUP_DEPTH, budget).unwrap(); + assert_eq!(result.document.layers.len(), 1, "one U8 surface fills the retained-byte allowance"); + assert_eq!(result.document.layers[0].name, "g0"); + assert!(result.warnings.iter().any(|warning| warning.contains("pixel memory")), "{:?}", result.warnings); + } + + #[test] + fn oversized_raster_does_not_consume_budget_for_later_layers() { + let svg = br##" + + + + + + + + + + + "##; + let result = import_parsed_with_budget("budget", parse(svg).unwrap(), MAX_GROUP_DEPTH, 64 * 64 * 4).unwrap(); + assert_eq!(result.document.layers.len(), 1, "the oversized group must not debit the budget"); + assert_eq!(result.document.layers[0].name, "small"); + assert!(result.warnings.iter().any(|w| w.contains("too large to rasterise")), "{:?}", result.warnings); + assert!(!result.warnings.iter().any(|w| w.contains("pixel memory budget")), "{:?}", result.warnings); + } + + #[test] + fn skipped_expensive_raster_does_not_hide_a_smaller_one() { + let svg = br##" + + + + + + + "##; + let result = import_parsed_with_budget("budget", parse(svg).unwrap(), MAX_GROUP_DEPTH, 32 * 32 * 4).unwrap(); + assert_eq!(result.document.layers.len(), 1); + assert_eq!(result.document.layers[0].name, "small"); + assert!(result.warnings.iter().any(|w| w.contains("some rasterised elements were omitted")), "{:?}", result.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3cd8de5b4c..6caf335768 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -202,14 +202,49 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = - PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; - let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); + // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as + // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a + // TIFF and opens only the composite, as one locked Background. The exporter's size estimate + // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB + // while its real layer data is a small fraction of that; it is not used here. PSB-style data + // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let force_psb = opts.force_psb || big; + let psd_opts = PsdExportOptions { + force_psb, + size_limit_psb: false, + merged_matte: false, + merged_compression: Compression::Raw, + maximize_compatibility: true, + document_path: opts.document_path.clone(), + }; + // TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap + // or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter + // otherwise writes in the document's own mode. + let plain; + let doc = if matches!(doc.mode, ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone) { + plain = Document { mode: doc.pixel_format().mode, ..doc.clone() }; + warnings.push(format!("{:?} document written as {:?}", doc.mode, plain.mode)); + &plain + } else { + doc + }; + let (mut file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = ImageSourceData::from_psd(&file).to_bytes(order)?; + let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { + let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; + let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); + ctl.check().map_err(|_| IoError::Cancelled)?; + warnings.extend(w); + warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); + file = psb; + ImageSourceData::from_psd(&file).to_bytes(order)? + } + r => r?, + }; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_LAYERS_DONE); warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); @@ -279,3 +314,54 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec